lucid-apple-mcp 0.3.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Randell Hendley / Lucid Systems LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # lucid-apple-mcp
2
+
3
+ MCP server that gives Claude and local LLMs access to Apple's on-device frameworks — Vision OCR, NSDataDetector, and Apple Intelligence FoundationModels. Everything runs on your Mac. Nothing leaves.
4
+
5
+ **Zero tokens consumed · Zero data leaves your Mac.**
6
+
7
+ ## Tools
8
+
9
+ | Tool | Engine | Needs Apple Intelligence | Input | Returns |
10
+ |---|---|---|---|---|
11
+ | `ocr` | Vision | No | `path` | plain text |
12
+ | `recognize_document` | Vision | No | `path` | `{transcript, tables}` |
13
+ | `detect` | NSDataDetector | No | `text` | JSON array |
14
+ | `extract` | FoundationModels | **Yes** | `text`, `want?` | JSON object |
15
+ | `classify` | FoundationModels | **Yes** | `text`, `labels` | one label |
16
+ | `summarize` | FoundationModels | **Yes** | `text` | summary string |
17
+ | `generate` | FoundationModels | **Yes** | `prompt`, `instructions?` | reply string |
18
+
19
+ Three capability tiers:
20
+
21
+ - **`ocr` + `detect`** — run on **any Apple Silicon Mac**. No Apple Intelligence, no macOS 26 required.
22
+ - **`recognize_document`** — requires **macOS 26** (Vision's `RecognizeDocumentsRequest`), but **not** Apple Intelligence.
23
+ - **`extract`, `classify`, `summarize`, `generate`** — require **macOS 26 + Apple Intelligence** enabled in System Settings.
24
+
25
+ ## Requirements
26
+
27
+ - Apple Silicon Mac
28
+ - Node.js 18+
29
+ - Xcode Command Line Tools (`xcode-select --install`) — to build the Swift helper
30
+ - macOS 26+ — for `recognize_document` and the four FoundationModels tools
31
+ - Apple Intelligence enabled — for `extract`, `classify`, `summarize`, `generate` only
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ git clone https://github.com/Lucid-Systems-LLC/Lucid-Apple-MCP.git
37
+ cd Lucid-Apple-MCP
38
+ npm install # compiles helper.swift → ./helper automatically (postinstall)
39
+ ```
40
+
41
+ `npm install` builds the Swift `helper` for you. On a non-Mac, or a Mac without the Xcode tools, it skips the build with a note instead of failing — run `npm run build` once the toolchain is present.
42
+
43
+ ## Add to Claude Code (CLI)
44
+
45
+ ```bash
46
+ claude mcp add lucid-apple "$(which node)" "$(pwd)/server.mjs"
47
+ ```
48
+
49
+ `$(which node)` bakes in the absolute path to your Node binary — which matters (see the note below).
50
+
51
+ ## Add to Claude Desktop
52
+
53
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "lucid-apple": {
59
+ "command": "/absolute/path/to/node",
60
+ "args": ["/absolute/path/to/lucid-apple-mcp/server.mjs"]
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ Use an **absolute path to `node`** — Claude Desktop is launched from the GUI and does not inherit your shell `PATH`, so a bare `"node"` fails with `spawn node ENOENT` (common with nvm or Homebrew). Find yours with `which node` (e.g. `/Users/you/.nvm/versions/node/v20.20.0/bin/node`). Use the absolute path to `server.mjs` too.
67
+
68
+ Restart Claude Desktop. The tools appear in the MCP panel.
69
+
70
+ ## Architecture
71
+
72
+ Node.js MCP server (`server.mjs`, stdio transport) spawns a compiled Swift binary (`helper`) once per tool call — one JSON request on stdin, one JSON result on stdout. The Swift binary bridges:
73
+
74
+ - **Vision** (`VNRecognizeTextRequest`, `RecognizeDocumentsRequest`) → OCR
75
+ - **NSDataDetector** → deterministic entity detection
76
+ - **FoundationModels** → Apple's on-device LLM
77
+
78
+ Stateless per call. No persistent process. Safe in air-gap when used with a local LLM client.
79
+
80
+ ## Privacy
81
+
82
+ Computation is fully on-device — files and text never leave the Mac. One honest caveat: when driving this from a cloud assistant (e.g. Claude Desktop), tool *results* are returned to that assistant and become part of the cloud conversation. For an end-to-end offline pipeline, drive the MCP from a local client like Voical.
83
+
84
+ ## Limitations
85
+
86
+ - `recognize_document` requires macOS 26; `extract`, `classify`, `summarize`, and `generate` require macOS 26 with Apple Intelligence enabled. On older macOS these return a clean "requires macOS 26" error — `ocr` and `detect` keep working.
87
+ - Apple's on-device model is fast and private — not a frontier model. Use it for short answers, drafts, and rewrites.
88
+ - `ocr` and `recognize_document` require absolute file paths.
89
+ - macOS only. No Windows or Linux support.
90
+
91
+ ## License
92
+
93
+ MIT — see [LICENSE](LICENSE).
94
+
95
+ ---
96
+
97
+ Built by [Lucid Systems LLC](https://lucidsystemsai.com) · Veteran owned · No VC · No cloud
package/USAGE.md ADDED
@@ -0,0 +1,145 @@
1
+ # Lucid Apple MCP — Usage
2
+
3
+ On-device Apple Intelligence, exposed as MCP tools. Everything runs locally on the Mac:
4
+ OCR via the **Vision** framework, entity detection via **NSDataDetector**, and
5
+ generation / extraction / classification / summarization via Apple's on-device
6
+ **FoundationModels** LLM. No cloud AI service is called for the work itself.
7
+
8
+ ## Requirements
9
+ - Apple-silicon Mac, **macOS 26+**.
10
+ - The four FoundationModels tools (`extract`, `classify`, `summarize`, `generate`)
11
+ require **Apple Intelligence enabled** in System Settings.
12
+ - `ocr` and `detect` work regardless — Vision and NSDataDetector are always available.
13
+
14
+ ## Tools at a glance
15
+
16
+ | Tool | Required | Optional | Returns | Engine | Needs Apple Intelligence |
17
+ |---|---|---|---|---|---|
18
+ | `ocr` | `path` | — | text (string) | Vision | No |
19
+ | `recognize_document` | `path` | — | JSON `{transcript, tables}` | Vision | No |
20
+ | `detect` | `text` | — | JSON array | NSDataDetector | No |
21
+ | `extract` | `text` | `want` | JSON object | FoundationModels | Yes |
22
+ | `classify` | `text`, `labels` | — | one label (string) | FoundationModels | Yes |
23
+ | `summarize` | `text` | — | summary (string) | FoundationModels | Yes |
24
+ | `generate` | `prompt` | `instructions` | reply (string) | FoundationModels | Yes |
25
+
26
+ ---
27
+
28
+ ## `ocr` — text from an image or PDF page
29
+ **One parameter: `path` (absolute).** Accepts `png`, `jpg`, `heic`, `tiff`, or a rendered
30
+ PDF page. Returns the recognized text, lines joined by newlines. ~0.2 s, fully offline.
31
+
32
+ ```json
33
+ { "tool": "ocr", "arguments": { "path": "/Users/you/Desktop/receipt.jpg" } }
34
+ ```
35
+
36
+ ## `recognize_document` — structured OCR (tables / forms / multi-column)
37
+ **One parameter: `path` (absolute).** Apple Vision's `RecognizeDocumentsRequest` — same
38
+ inputs as `ocr`, but returns **structured JSON**:
39
+ `{ "transcript": "…full reading-order text…", "tables": [ [ [cell, …], …rows ], …tables ] }`.
40
+ Use this instead of `ocr` whenever **layout matters** — tables, forms, receipts, multi-column
41
+ pages. `ocr` flattens a table into a stream of words; this reconstructs it as rows of cells.
42
+ Offline; no Apple Intelligence required.
43
+
44
+ ```json
45
+ { "tool": "recognize_document", "arguments": { "path": "/Users/you/Downloads/invoice.png" } }
46
+ ```
47
+
48
+ ## `detect` — dates / links / phones / addresses (deterministic)
49
+ **One parameter: `text`.** Runs `NSDataDetector` — no model, instant, deterministic.
50
+ Returns a JSON array; **every item has `match` (the literal substring) + `type`**, plus
51
+ one type-specific field:
52
+
53
+ | type | extra field | notes |
54
+ |---|---|---|
55
+ | `phone` | `value` | normalized phone string |
56
+ | `link` | `url` | absolute URL |
57
+ | `date` | `iso` | ISO-8601 **UTC**, resolved using the machine's local timezone |
58
+ | `address` | — | the full address is in `match` |
59
+
60
+ ```jsonc
61
+ // detect("Call +1 (512) 555-0123 or see https://voicalai.com — meeting June 14, 2026 at 3:00 PM, 123 Main St, Austin TX 78701")
62
+ [
63
+ { "match": "+1 (512) 555-0123", "type": "phone", "value": "+1 (512) 555-0123" },
64
+ { "match": "https://voicalai.com", "type": "link", "url": "https://voicalai.com" },
65
+ { "match": "June 14, 2026 at 3:00 PM", "type": "date", "iso": "2026-06-14T20:00:00Z" },
66
+ { "match": "123 Main St, Austin TX 78701", "type": "address" }
67
+ ]
68
+ ```
69
+
70
+ ## `extract` — structured JSON from text
71
+ **`text` (required) + `want` (optional).** Uses FoundationModels to pull structured
72
+ fields and returns a JSON object. Today's date is injected into the prompt, so relative
73
+ dates ("next Tuesday") resolve to absolute ones. `want` defaults to key fields / named
74
+ entities / dates / action items.
75
+
76
+ ```json
77
+ { "tool": "extract", "arguments": { "text": "...", "want": "name, title, company, phone, email" } }
78
+ ```
79
+
80
+ ## `classify` — one label from a set
81
+ **`text` + `labels` (non-empty array).** Returns exactly one of the provided labels.
82
+
83
+ ```json
84
+ { "tool": "classify", "arguments": { "text": "...", "labels": ["invoice", "contract", "medical", "junk"] } }
85
+ ```
86
+
87
+ ## `summarize` — concise summary
88
+ **`text`.** Returns a short summary string (FoundationModels).
89
+
90
+ ## `generate` — quick on-device answer / draft / rewrite
91
+ **`prompt` (required) + `instructions` (optional system-style guidance for tone/role/format).**
92
+ Apple's on-device model — concise; good for short answers, drafting, and rewriting; not
93
+ for deep reasoning.
94
+
95
+ ```json
96
+ { "tool": "generate", "arguments": { "prompt": "Rewrite this more formally: ...", "instructions": "You are a concise editor." } }
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Chaining — OCR, then mine the text
102
+ The tools compose. `ocr` lifts text off an image; the rest operate on that text:
103
+
104
+ ```
105
+ ocr(image) → raw text
106
+ ├─ extract(text, want="…") → structured JSON
107
+ ├─ detect(text) → dates / links / phones / addresses (deterministic)
108
+ ├─ classify(text, labels=[…]) → one label
109
+ ├─ summarize(text) → short summary
110
+ └─ generate(prompt=…) → free-form reply
111
+ ```
112
+
113
+ **Recipe — scan a business card**
114
+ 1. `text = ocr("/…/card.jpg")`
115
+ 2. `fields = extract(text, want="name, title, company, phone, email, address")`
116
+ 3. `entities = detect(text)` → ground the phone / email / address deterministically.
117
+
118
+ **Recipe — triage a scanned document**
119
+ 1. `text = ocr("/…/letter.png")`
120
+ 2. `category = classify(text, labels=["invoice", "contract", "medical", "junk"])`
121
+ 3. `summary = summarize(text)`
122
+
123
+ > **Accuracy tip:** for hard facts (dates, phones, links, addresses), trust `detect`
124
+ > (deterministic) over the model's reading of them; use `extract` for the semantics.
125
+
126
+ ---
127
+
128
+ ## Conversational use (any MCP client)
129
+ Once connected, just ask:
130
+ - *"OCR /Users/me/Desktop/scan.png and pull out any dates and action items."*
131
+ - *"Classify this note as urgent, normal, or low priority."*
132
+
133
+ The client calls the tools for you.
134
+
135
+ ## Offline / privacy
136
+ The **computation is on-device** — the image/file never leaves the Mac, and no cloud OCR
137
+ or AI API is called. One honest distinction when driving this from a **cloud assistant**
138
+ (e.g. Claude Desktop): the tool *result* is returned to that assistant and becomes part
139
+ of the cloud conversation. For an **end-to-end offline** pipeline (results never leaving
140
+ the machine), drive the MCP from a local client — e.g. Voical's local model.
141
+
142
+ ## Architecture
143
+ A Node MCP server (`server.mjs`, `@modelcontextprotocol/sdk`, stdio) spawns a compiled
144
+ Swift helper (`helper`) once per call — one JSON request on stdin, one JSON line on
145
+ stdout — bridging Vision, NSDataDetector, and FoundationModels. Fully offline.
package/helper.swift ADDED
@@ -0,0 +1,209 @@
1
+ // lucid-apple — Swift helper. Reads ONE JSON request {verb, ...} on stdin, runs an
2
+ // on-device Apple capability, writes ONE JSON line to stdout. Fully offline. Spawned
3
+ // per-call by the Node MCP server.
4
+ // Vision: ocr (any macOS) · recognize_document (macOS 26)
5
+ // NSDataDetector: detect (any macOS)
6
+ // FoundationModels: generate · summarize · extract · classify (macOS 26 + Apple Intelligence)
7
+ //
8
+ // Portability: `ocr` and `detect` build and run on any Apple Silicon Mac. The macOS-26
9
+ // APIs (FoundationModels + Vision's RecognizeDocumentsRequest) are guarded by
10
+ // `#if canImport(FoundationModels)` (so the file compiles on an older SDK) and
11
+ // `@available(macOS 26.0, *)` / `if #available` (so it runs on an older deployment target).
12
+ import Foundation
13
+ import Vision
14
+
15
+ #if canImport(FoundationModels)
16
+ import FoundationModels
17
+ #endif
18
+
19
+ enum HErr: Error, CustomStringConvertible {
20
+ case bad(String)
21
+ var description: String { switch self { case .bad(let m): return m } }
22
+ }
23
+
24
+ @main
25
+ struct Helper {
26
+ static func main() async {
27
+ let input = FileHandle.standardInput.readDataToEndOfFile()
28
+ guard let req = (try? JSONSerialization.jsonObject(with: input)) as? [String: Any],
29
+ let verb = req["verb"] as? String else {
30
+ emit(["ok": false, "error": "invalid request — expected JSON {\"verb\": ...}"])
31
+ return
32
+ }
33
+ do {
34
+ switch verb {
35
+ // ── Always available (any Apple Silicon Mac) ───────────────────────────
36
+ case "ocr":
37
+ guard let path = req["path"] as? String else { throw HErr.bad("ocr needs 'path'") }
38
+ emit(["ok": true, "text": try ocr(path: path)])
39
+
40
+ case "detect":
41
+ guard let text = req["text"] as? String else { throw HErr.bad("detect needs 'text'") }
42
+ let data = try JSONSerialization.data(withJSONObject: detectData(in: text))
43
+ emit(["ok": true, "text": String(data: data, encoding: .utf8) ?? "[]"])
44
+
45
+ // ── macOS 26 + Apple Intelligence (FoundationModels) ───────────────────
46
+ case "generate":
47
+ guard let prompt = req["prompt"] as? String else { throw HErr.bad("generate needs 'prompt'") }
48
+ #if canImport(FoundationModels)
49
+ if #available(macOS 26.0, *) {
50
+ emit(["ok": true, "text": try await generate(prompt, instructions: req["instructions"] as? String)])
51
+ } else { emit(unavailable26("generate", needsAI: true)) }
52
+ #else
53
+ emit(unavailable26("generate", needsAI: true))
54
+ #endif
55
+
56
+ case "summarize":
57
+ guard let text = req["text"] as? String else { throw HErr.bad("summarize needs 'text'") }
58
+ #if canImport(FoundationModels)
59
+ if #available(macOS 26.0, *) {
60
+ let p = "Summarize the following in a few concise sentences. Output only the summary.\n\n\(text)"
61
+ emit(["ok": true, "text": try await generate(p, instructions: nil)])
62
+ } else { emit(unavailable26("summarize", needsAI: true)) }
63
+ #else
64
+ emit(unavailable26("summarize", needsAI: true))
65
+ #endif
66
+
67
+ case "extract":
68
+ guard let text = req["text"] as? String else { throw HErr.bad("extract needs 'text'") }
69
+ #if canImport(FoundationModels)
70
+ if #available(macOS 26.0, *) {
71
+ let want = req["want"] as? String ?? "the key fields, named entities, dates/times, and action items"
72
+ let df = DateFormatter(); df.dateFormat = "EEEE, MMMM d, yyyy"
73
+ let p = "Today is \(df.string(from: Date())). From the TEXT below, extract \(want). Resolve any relative dates (e.g. \"next Tuesday\") to absolute calendar dates. Respond with ONLY valid minified JSON — no prose, no markdown fences.\n\nTEXT:\n\(text)"
74
+ let raw = try await generate(p, instructions: "You are a precise information-extraction engine. Output only valid JSON.")
75
+ emit(["ok": true, "text": stripFences(raw)])
76
+ } else { emit(unavailable26("extract", needsAI: true)) }
77
+ #else
78
+ emit(unavailable26("extract", needsAI: true))
79
+ #endif
80
+
81
+ case "classify":
82
+ guard let text = req["text"] as? String, let labels = req["labels"] as? [String], !labels.isEmpty else {
83
+ throw HErr.bad("classify needs 'text' and a non-empty 'labels' array")
84
+ }
85
+ #if canImport(FoundationModels)
86
+ if #available(macOS 26.0, *) {
87
+ let p = "Classify the TEXT into EXACTLY ONE of these labels: \(labels.joined(separator: ", ")). Reply with only the chosen label, nothing else.\n\nTEXT:\n\(text)"
88
+ emit(["ok": true, "text": try await generate(p, instructions: "You are a classifier. Reply with exactly one of the allowed labels.")])
89
+ } else { emit(unavailable26("classify", needsAI: true)) }
90
+ #else
91
+ emit(unavailable26("classify", needsAI: true))
92
+ #endif
93
+
94
+ // ── macOS 26 (Vision RecognizeDocumentsRequest), no Apple Intelligence ──
95
+ case "recognize_document", "recognize_doc":
96
+ guard let path = req["path"] as? String else { throw HErr.bad("recognize_document needs 'path'") }
97
+ #if canImport(FoundationModels)
98
+ if #available(macOS 26.0, *) {
99
+ let docObj = try await recognizeDocument(path: path)
100
+ let docData = try JSONSerialization.data(withJSONObject: docObj)
101
+ emit(["ok": true, "text": String(data: docData, encoding: .utf8) ?? "{}"])
102
+ } else { emit(unavailable26("recognize_document", needsAI: false)) }
103
+ #else
104
+ emit(unavailable26("recognize_document", needsAI: false))
105
+ #endif
106
+
107
+ default:
108
+ emit(["ok": false, "error": "unknown verb '\(verb)'"])
109
+ }
110
+ } catch {
111
+ emit(["ok": false, "error": "\(error)"])
112
+ }
113
+ }
114
+
115
+ // Clean, user-facing error for the macOS-26-only verbs when run on an older OS / SDK.
116
+ static func unavailable26(_ verb: String, needsAI: Bool) -> [String: Any] {
117
+ let why = needsAI
118
+ ? "requires macOS 26 with Apple Intelligence enabled"
119
+ : "requires macOS 26 (Vision RecognizeDocumentsRequest)"
120
+ return ["ok": false, "error": "\(verb) \(why). ocr and detect work on any Apple Silicon Mac."]
121
+ }
122
+
123
+ static func ocr(path: String) throws -> String {
124
+ let request = VNRecognizeTextRequest()
125
+ request.recognitionLevel = .accurate
126
+ try VNImageRequestHandler(url: URL(fileURLWithPath: path), options: [:]).perform([request])
127
+ return (request.results ?? [])
128
+ .compactMap { $0.topCandidates(1).first?.string }
129
+ .joined(separator: "\n")
130
+ }
131
+
132
+ // NSDataDetector — offline, deterministic detection of dates / links / phones / addresses.
133
+ static func detectData(in text: String) -> [[String: Any]] {
134
+ let types: NSTextCheckingResult.CheckingType = [.date, .link, .phoneNumber, .address]
135
+ guard let detector = try? NSDataDetector(types: types.rawValue) else { return [] }
136
+ let ns = text as NSString
137
+ var out: [[String: Any]] = []
138
+ detector.enumerateMatches(in: text, range: NSRange(location: 0, length: ns.length)) { m, _, _ in
139
+ guard let m = m else { return }
140
+ var item: [String: Any] = ["match": ns.substring(with: m.range)]
141
+ switch m.resultType {
142
+ case .date:
143
+ item["type"] = "date"
144
+ if let d = m.date { item["iso"] = ISO8601DateFormatter().string(from: d) }
145
+ case .link:
146
+ item["type"] = "link"; if let u = m.url { item["url"] = u.absoluteString }
147
+ case .phoneNumber:
148
+ item["type"] = "phone"; if let p = m.phoneNumber { item["value"] = p }
149
+ case .address:
150
+ item["type"] = "address" // full address string is in "match"
151
+ default:
152
+ item["type"] = "other"
153
+ }
154
+ out.append(item)
155
+ }
156
+ return out
157
+ }
158
+
159
+ // Some small-model outputs wrap JSON in ```json … ``` despite instructions; strip it.
160
+ static func stripFences(_ s: String) -> String {
161
+ var t = s.trimmingCharacters(in: .whitespacesAndNewlines)
162
+ guard t.hasPrefix("```") else { return t }
163
+ if let nl = t.firstIndex(of: "\n") { t = String(t[t.index(after: nl)...]) }
164
+ if let r = t.range(of: "```", options: .backwards) { t = String(t[..<r.lowerBound]) }
165
+ return t.trimmingCharacters(in: .whitespacesAndNewlines)
166
+ }
167
+
168
+ static func emit(_ obj: [String: Any]) {
169
+ guard let data = try? JSONSerialization.data(withJSONObject: obj) else { return }
170
+ FileHandle.standardOutput.write(data)
171
+ FileHandle.standardOutput.write(Data("\n".utf8))
172
+ }
173
+ }
174
+
175
+ #if canImport(FoundationModels)
176
+ @available(macOS 26.0, *)
177
+ extension Helper {
178
+ static func generate(_ prompt: String, instructions: String?) async throws -> String {
179
+ let model = SystemLanguageModel.default
180
+ guard case .available = model.availability else {
181
+ throw HErr.bad("Apple Intelligence model unavailable: \(model.availability)")
182
+ }
183
+ let session = LanguageModelSession()
184
+ let full = instructions.map { "\($0)\n\n\(prompt)" } ?? prompt
185
+ return try await session.respond(to: full).content
186
+ }
187
+
188
+ // Vision RecognizeDocumentsRequest — STRUCTURED extraction (full transcript + TABLES
189
+ // as rows/cells), preserving the layout that plain OCR (VNRecognizeTextRequest) flattens.
190
+ static func recognizeDocument(path: String) async throws -> [String: Any] {
191
+ let request = RecognizeDocumentsRequest()
192
+ let observations = try await request.perform(on: URL(fileURLWithPath: path))
193
+ guard let document = observations.first?.document else {
194
+ throw HErr.bad("no document recognized in \(path)")
195
+ }
196
+ var out: [String: Any] = ["transcript": document.text.transcript]
197
+ var tables: [[[String]]] = []
198
+ for table in document.tables {
199
+ var rows: [[String]] = []
200
+ for row in table.rows {
201
+ rows.append(row.map { $0.content.text.transcript })
202
+ }
203
+ tables.append(rows)
204
+ }
205
+ out["tables"] = tables
206
+ return out
207
+ }
208
+ }
209
+ #endif
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "lucid-apple-mcp",
3
+ "version": "0.3.0",
4
+ "mcpName": "io.github.Lucid-Systems-LLC/lucid-apple-mcp",
5
+ "description": "Offline MCP server exposing Apple's on-device frameworks to Claude and local LLMs — Vision OCR with table-preserving document recognition, NSDataDetector, and Apple Intelligence Foundation Models. Runs entirely on your Mac: no cloud, no API keys, zero tokens consumed, zero data leaves the machine. macOS only.",
6
+ "type": "module",
7
+ "bin": { "lucid-apple-mcp": "./server.mjs" },
8
+ "scripts": {
9
+ "build": "swiftc -parse-as-library helper.swift -o helper",
10
+ "postinstall": "node -e \"const{spawnSync}=require('node:child_process');if(process.platform!=='darwin'){console.warn('[lucid-apple-mcp] non-macOS host - skipping Swift helper build (this server is macOS only).');process.exit(0)}const r=spawnSync('swiftc',['-parse-as-library','helper.swift','-o','helper'],{stdio:'inherit'});if(r.status!==0)console.warn('[lucid-apple-mcp] could not build helper - install Xcode Command Line Tools (xcode-select --install), then run: npm run build');process.exit(0)\"",
11
+ "smoke": "node smoke.mjs"
12
+ },
13
+ "keywords": [
14
+ "mcp",
15
+ "model-context-protocol",
16
+ "apple",
17
+ "apple-intelligence",
18
+ "foundation-models",
19
+ "vision",
20
+ "ocr",
21
+ "document-ocr",
22
+ "tables",
23
+ "nsdatadetector",
24
+ "on-device",
25
+ "offline",
26
+ "local",
27
+ "privacy",
28
+ "macos",
29
+ "no-cloud",
30
+ "claude"
31
+ ],
32
+ "repository": { "type": "git", "url": "git+https://github.com/Lucid-Systems-LLC/Lucid-Apple-MCP.git" },
33
+ "homepage": "https://github.com/Lucid-Systems-LLC/Lucid-Apple-MCP",
34
+ "bugs": { "url": "https://github.com/Lucid-Systems-LLC/Lucid-Apple-MCP/issues" },
35
+ "author": "Lucid Systems LLC",
36
+ "license": "MIT",
37
+ "engines": { "node": ">=18" },
38
+ "files": ["server.mjs", "helper.swift", "README.md", "USAGE.md", "LICENSE"],
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.29.0"
41
+ }
42
+ }
package/server.mjs ADDED
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+ // lucid-apple — MCP server exposing Apple's on-device Intelligence
3
+ // (OFFLINE) as tools. Each call spawns the Swift `helper` binary with a JSON request
4
+ // on stdin and returns its JSON result. Nothing leaves the machine — safe in air-gap.
5
+ // FoundationModels: generate · summarize · extract · classify
6
+ // Vision: ocr · recognize_document
7
+ // NSDataDetector: detect
8
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
9
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
10
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
11
+ import { spawn } from 'node:child_process'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { dirname, join } from 'node:path'
14
+
15
+ const HELPER = join(dirname(fileURLToPath(import.meta.url)), 'helper')
16
+
17
+ const HELPER_TIMEOUT_MS = 60000
18
+
19
+ // Scan the helper's stdout from the last line up for the first line that parses as JSON.
20
+ // Apple's Vision (RecognizeDocumentsRequest) can print a `VTEST:` diagnostic to stdout
21
+ // AFTER our JSON on the error path; a naive last-line parse would choke on it.
22
+ function lastJsonLine(out) {
23
+ const lines = out.split('\n')
24
+ for (let i = lines.length - 1; i >= 0; i--) {
25
+ const s = lines[i].trim()
26
+ if (!s) continue
27
+ try {
28
+ return JSON.parse(s)
29
+ } catch {
30
+ /* keep scanning */
31
+ }
32
+ }
33
+ return undefined
34
+ }
35
+
36
+ function callHelper(req) {
37
+ return new Promise((resolve) => {
38
+ const p = spawn(HELPER, [], { stdio: ['pipe', 'pipe', 'pipe'] })
39
+ let out = ''
40
+ let err = ''
41
+ let settled = false
42
+ const done = (value) => {
43
+ if (settled) return
44
+ settled = true
45
+ clearTimeout(timer)
46
+ resolve(value)
47
+ }
48
+ // A stalled helper (e.g. the on-device model wedging) must not hang the call forever.
49
+ const timer = setTimeout(() => {
50
+ try {
51
+ p.kill('SIGKILL')
52
+ } catch {
53
+ /* already gone */
54
+ }
55
+ done({ ok: false, error: `helper timed out after ${HELPER_TIMEOUT_MS}ms` })
56
+ }, HELPER_TIMEOUT_MS)
57
+ p.stdout.on('data', (d) => (out += d))
58
+ p.stderr.on('data', (d) => (err += d))
59
+ p.on('error', (e) => done({ ok: false, error: `cannot spawn helper: ${e.message}` }))
60
+ // Without this, an EPIPE when the helper exits early throws an *uncaught* exception
61
+ // that takes down the whole server. Swallow it; `close` reports the real outcome.
62
+ p.stdin.on('error', () => {})
63
+ p.on('close', () => {
64
+ const parsed = lastJsonLine(out)
65
+ done(parsed !== undefined ? parsed : { ok: false, error: err.trim() || 'helper returned no JSON' })
66
+ })
67
+ p.stdin.write(JSON.stringify(req))
68
+ p.stdin.end()
69
+ })
70
+ }
71
+
72
+ const TOOLS = [
73
+ {
74
+ name: 'ocr',
75
+ description:
76
+ 'Extract text from an image or rendered PDF page using on-device Apple Vision OCR. Fully offline, ~0.2s.',
77
+ inputSchema: {
78
+ type: 'object',
79
+ properties: { path: { type: 'string', description: 'Absolute path to an image (png/jpg/heic/tiff) or rendered PDF page.' } },
80
+ required: ['path']
81
+ }
82
+ },
83
+ {
84
+ name: 'generate',
85
+ description:
86
+ "Ask Apple's on-device Intelligence model (offline). Good for quick answers, drafting, rewriting. Concise; not for deep reasoning.",
87
+ inputSchema: {
88
+ type: 'object',
89
+ properties: {
90
+ prompt: { type: 'string', description: 'The prompt / question.' },
91
+ instructions: { type: 'string', description: 'Optional system-style guidance (tone, role, format).' }
92
+ },
93
+ required: ['prompt']
94
+ }
95
+ },
96
+ {
97
+ name: 'summarize',
98
+ description: "Summarize text concisely using Apple's on-device Intelligence model (offline).",
99
+ inputSchema: {
100
+ type: 'object',
101
+ properties: { text: { type: 'string', description: 'The text to summarize.' } },
102
+ required: ['text']
103
+ }
104
+ },
105
+ {
106
+ name: 'extract',
107
+ description:
108
+ "Extract STRUCTURED data from text with Apple's on-device model (offline) — returns JSON. Great for pulling an event, action items, or contact fields out of an email / note / message.",
109
+ inputSchema: {
110
+ type: 'object',
111
+ properties: {
112
+ text: { type: 'string', description: 'The source text.' },
113
+ want: {
114
+ type: 'string',
115
+ description: "What to extract, e.g. 'the meeting date, time, attendees, and location' or 'all action items'. Defaults to key fields/entities/dates/action-items."
116
+ }
117
+ },
118
+ required: ['text']
119
+ }
120
+ },
121
+ {
122
+ name: 'classify',
123
+ description: "Classify text into EXACTLY ONE of a provided set of labels, on-device (offline).",
124
+ inputSchema: {
125
+ type: 'object',
126
+ properties: {
127
+ text: { type: 'string', description: 'The text to classify.' },
128
+ labels: { type: 'array', items: { type: 'string' }, description: 'The allowed labels (pick exactly one).' }
129
+ },
130
+ required: ['text', 'labels']
131
+ }
132
+ },
133
+ {
134
+ name: 'detect',
135
+ description:
136
+ 'Detect dates, links, phone numbers, and addresses in text (NSDataDetector — fully offline + deterministic). Returns a JSON array of items. Pairs well with extract for grounding calendar/contact data.',
137
+ inputSchema: {
138
+ type: 'object',
139
+ properties: { text: { type: 'string', description: 'The text to scan.' } },
140
+ required: ['text']
141
+ }
142
+ },
143
+ {
144
+ name: 'recognize_document',
145
+ description:
146
+ 'STRUCTURED document OCR via Apple Vision (RecognizeDocumentsRequest, offline). Returns JSON {transcript, tables} and preserves TABLES as rows/cells + reading order — unlike `ocr`, which flattens layout. Use for tables, forms, receipts, and multi-column documents.',
147
+ inputSchema: {
148
+ type: 'object',
149
+ properties: { path: { type: 'string', description: 'Absolute path to an image (png/jpg/heic/tiff) or rendered PDF page.' } },
150
+ required: ['path']
151
+ }
152
+ }
153
+ ]
154
+
155
+ const server = new Server(
156
+ { name: 'lucid-apple', version: '0.3.0' },
157
+ { capabilities: { tools: {} } }
158
+ )
159
+
160
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
161
+
162
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
163
+ const { name, arguments: args = {} } = request.params
164
+ const res = await callHelper({ verb: name, ...args })
165
+ if (!res.ok) {
166
+ return { content: [{ type: 'text', text: `Error: ${res.error}` }], isError: true }
167
+ }
168
+ return { content: [{ type: 'text', text: res.text ?? '' }] }
169
+ })
170
+
171
+ await server.connect(new StdioServerTransport())