marrow-mcp 0.2.0__tar.gz

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.
@@ -0,0 +1,9 @@
1
+ data/
2
+ *.db
3
+ *.sqlite
4
+ .app-token
5
+ .mcp-token
6
+ __pycache__/
7
+ dist/
8
+ *.egg-info/
9
+ .venv-build/
@@ -0,0 +1,7 @@
1
+ FROM python:3.12-slim
2
+ RUN pip install --no-cache-dir segno
3
+ WORKDIR /app
4
+ COPY marrow_server.py .
5
+ VOLUME /data
6
+ EXPOSE 8800
7
+ CMD ["python3", "marrow_server.py", "--data", "/data"]
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Skeleton Army
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.
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.5
2
+ Name: marrow-mcp
3
+ Version: 0.2.0
4
+ Summary: Self-hosted mirror and MCP server for Marrow, giving AI agents read-only access to your own Apple Health data
5
+ Project-URL: Homepage, https://skeletonarmy.tech/
6
+ Project-URL: Documentation, https://skeletonarmy.tech/mcp
7
+ Project-URL: Source, https://github.com/DukeAidanHall/marrow-mcp
8
+ Author: Skeleton Army
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Skeleton Army
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: agents,apple-health,healthkit,mcp,model-context-protocol,quantified-self
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Intended Audience :: End Users/Desktop
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
37
+ Requires-Python: >=3.10
38
+ Provides-Extra: qr
39
+ Requires-Dist: segno>=1.6; extra == 'qr'
40
+ Description-Content-Type: text/markdown
41
+
42
+ # marrow-mcp
43
+
44
+ Give your AI agents read-only access to your own Apple Health data.
45
+
46
+ [Marrow](https://skeletonarmy.tech/) is a free iOS app that pulls 174 Apple
47
+ Health metrics, your food diary and your workouts into one place. This repo is
48
+ its **self-hosted companion server**: one Python file, standard library only,
49
+ that keeps a SQLite mirror of your data on hardware you own and serves it over
50
+ the Model Context Protocol so agents can query it 24/7, phone in your pocket.
51
+
52
+ Nothing here talks to a cloud. Data flows phone → your machine, full stop.
53
+
54
+ ## Run it
55
+
56
+ ```sh
57
+ python3 marrow_server.py # prints a pairing URL
58
+ python3 marrow_server.py --port 8800 --data ./data
59
+ ```
60
+
61
+ or
62
+
63
+ ```sh
64
+ docker compose up -d
65
+ ```
66
+
67
+ Open the pairing URL on any screen, then in Marrow: **Server → Pair with a
68
+ server → scan**. From then on the app's background pushes keep the mirror
69
+ current with the app closed.
70
+
71
+ ## Connect an agent
72
+
73
+ The server speaks MCP over Streamable HTTP (JSON-RPC 2.0, protocol version
74
+ `2025-06-18`), authenticated with a bearer token it generates on first run and
75
+ prints alongside the pairing link.
76
+
77
+ **Claude Code**
78
+
79
+ ```sh
80
+ claude mcp add --transport http marrow \
81
+ http://<server-ip>:8800/mcp \
82
+ --header "Authorization: Bearer <mcp token>"
83
+ ```
84
+
85
+ **Claude Desktop, Cursor, or anything using an `mcp.json`**
86
+
87
+ ```json
88
+ {
89
+ "mcpServers": {
90
+ "marrow": {
91
+ "type": "http",
92
+ "url": "http://<server-ip>:8800/mcp",
93
+ "headers": { "Authorization": "Bearer <mcp token>" }
94
+ }
95
+ }
96
+ }
97
+ ```
98
+
99
+ **Check it by hand**
100
+
101
+ ```sh
102
+ curl -s http://<server-ip>:8800/mcp \
103
+ -H "Authorization: Bearer <mcp token>" \
104
+ -H "Content-Type: application/json" \
105
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
106
+ ```
107
+
108
+ ## Tools
109
+
110
+ All read-only. Nothing an agent can call writes to your health record.
111
+
112
+ | Tool | Returns |
113
+ |---|---|
114
+ | `health_summary` | Recent days across activity, heart, sleep and nutrition |
115
+ | `list_metrics` | Every metric in the mirror, with units and coverage |
116
+ | `metric_daily` | Daily values for any metric, up to 400 days |
117
+ | `metric_samples` | Raw records with timestamps and source devices |
118
+ | `workouts` | Workouts, including set-by-set strength detail |
119
+
120
+ Then ask your agent things like *"how did my sleep change once I started
121
+ training in the mornings?"* and let it go and look.
122
+
123
+ ## The other three ways in
124
+
125
+ This server is one of four, and you do not need it to give agents access:
126
+
127
+ 1. **On-device MCP.** Marrow runs the same MCP surface on the iPhone itself at
128
+ `http://<phone-ip>:21212/mcp`, answering while the app is running. Richest
129
+ data, zero extra hardware.
130
+ 2. **This server.** Always-on mirror on your own box.
131
+ 3. **Relay gateway.** Serves the mirror when the phone is asleep.
132
+ 4. **Manual files.** CSV, JSON and GPX out of the share sheet, plus scheduled
133
+ webhook pushes to any URL (Home Assistant, n8n, your own endpoint).
134
+
135
+ All four are in the free tier. Setup for all of them:
136
+ <https://skeletonarmy.tech/mcp>.
137
+
138
+ ## Security
139
+
140
+ - Both servers are built for a LAN or a tailnet. **Do not port-forward this to
141
+ the open internet.** If you need it remotely, front it with
142
+ `tailscale serve`, Caddy, or an equivalent so the transport is HTTPS and the
143
+ device is authenticated before the bearer token is ever presented.
144
+ - Two separate tokens live in `--data`: `.app-token` for the phone's pushes and
145
+ `.mcp-token` for agents. Rotating one does not disturb the other.
146
+ - Ingest is idempotent and UUID-keyed, so a replayed batch cannot double-count.
147
+
148
+ ## Status
149
+
150
+ Marrow is in TestFlight beta (0.2.0, build 85), iOS 17+, and requires Apple
151
+ Health. [Ask for an invite.](https://skeletonarmy.tech/#get)
152
+
153
+ The iOS app is closed source; this server is not, because you should be able to
154
+ read anything you are asked to run on your own hardware.
155
+
156
+ ## Licence
157
+
158
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,117 @@
1
+ # marrow-mcp
2
+
3
+ Give your AI agents read-only access to your own Apple Health data.
4
+
5
+ [Marrow](https://skeletonarmy.tech/) is a free iOS app that pulls 174 Apple
6
+ Health metrics, your food diary and your workouts into one place. This repo is
7
+ its **self-hosted companion server**: one Python file, standard library only,
8
+ that keeps a SQLite mirror of your data on hardware you own and serves it over
9
+ the Model Context Protocol so agents can query it 24/7, phone in your pocket.
10
+
11
+ Nothing here talks to a cloud. Data flows phone → your machine, full stop.
12
+
13
+ ## Run it
14
+
15
+ ```sh
16
+ python3 marrow_server.py # prints a pairing URL
17
+ python3 marrow_server.py --port 8800 --data ./data
18
+ ```
19
+
20
+ or
21
+
22
+ ```sh
23
+ docker compose up -d
24
+ ```
25
+
26
+ Open the pairing URL on any screen, then in Marrow: **Server → Pair with a
27
+ server → scan**. From then on the app's background pushes keep the mirror
28
+ current with the app closed.
29
+
30
+ ## Connect an agent
31
+
32
+ The server speaks MCP over Streamable HTTP (JSON-RPC 2.0, protocol version
33
+ `2025-06-18`), authenticated with a bearer token it generates on first run and
34
+ prints alongside the pairing link.
35
+
36
+ **Claude Code**
37
+
38
+ ```sh
39
+ claude mcp add --transport http marrow \
40
+ http://<server-ip>:8800/mcp \
41
+ --header "Authorization: Bearer <mcp token>"
42
+ ```
43
+
44
+ **Claude Desktop, Cursor, or anything using an `mcp.json`**
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "marrow": {
50
+ "type": "http",
51
+ "url": "http://<server-ip>:8800/mcp",
52
+ "headers": { "Authorization": "Bearer <mcp token>" }
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ **Check it by hand**
59
+
60
+ ```sh
61
+ curl -s http://<server-ip>:8800/mcp \
62
+ -H "Authorization: Bearer <mcp token>" \
63
+ -H "Content-Type: application/json" \
64
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
65
+ ```
66
+
67
+ ## Tools
68
+
69
+ All read-only. Nothing an agent can call writes to your health record.
70
+
71
+ | Tool | Returns |
72
+ |---|---|
73
+ | `health_summary` | Recent days across activity, heart, sleep and nutrition |
74
+ | `list_metrics` | Every metric in the mirror, with units and coverage |
75
+ | `metric_daily` | Daily values for any metric, up to 400 days |
76
+ | `metric_samples` | Raw records with timestamps and source devices |
77
+ | `workouts` | Workouts, including set-by-set strength detail |
78
+
79
+ Then ask your agent things like *"how did my sleep change once I started
80
+ training in the mornings?"* and let it go and look.
81
+
82
+ ## The other three ways in
83
+
84
+ This server is one of four, and you do not need it to give agents access:
85
+
86
+ 1. **On-device MCP.** Marrow runs the same MCP surface on the iPhone itself at
87
+ `http://<phone-ip>:21212/mcp`, answering while the app is running. Richest
88
+ data, zero extra hardware.
89
+ 2. **This server.** Always-on mirror on your own box.
90
+ 3. **Relay gateway.** Serves the mirror when the phone is asleep.
91
+ 4. **Manual files.** CSV, JSON and GPX out of the share sheet, plus scheduled
92
+ webhook pushes to any URL (Home Assistant, n8n, your own endpoint).
93
+
94
+ All four are in the free tier. Setup for all of them:
95
+ <https://skeletonarmy.tech/mcp>.
96
+
97
+ ## Security
98
+
99
+ - Both servers are built for a LAN or a tailnet. **Do not port-forward this to
100
+ the open internet.** If you need it remotely, front it with
101
+ `tailscale serve`, Caddy, or an equivalent so the transport is HTTPS and the
102
+ device is authenticated before the bearer token is ever presented.
103
+ - Two separate tokens live in `--data`: `.app-token` for the phone's pushes and
104
+ `.mcp-token` for agents. Rotating one does not disturb the other.
105
+ - Ingest is idempotent and UUID-keyed, so a replayed batch cannot double-count.
106
+
107
+ ## Status
108
+
109
+ Marrow is in TestFlight beta (0.2.0, build 85), iOS 17+, and requires Apple
110
+ Health. [Ask for an invite.](https://skeletonarmy.tech/#get)
111
+
112
+ The iOS app is closed source; this server is not, because you should be able to
113
+ read anything you are asked to run on your own hardware.
114
+
115
+ ## Licence
116
+
117
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,8 @@
1
+ services:
2
+ marrow:
3
+ build: .
4
+ ports:
5
+ - "8800:8800"
6
+ volumes:
7
+ - ./data:/data
8
+ restart: unless-stopped
@@ -0,0 +1,557 @@
1
+ #!/usr/bin/env python3
2
+ """Marrow self-hosted server — pair once, own your mirror, serve your agents.
3
+
4
+ One file, Python stdlib only. Run it on anything that stays on (Raspberry
5
+ Pi, NAS, home server, old laptop):
6
+
7
+ python3 marrow_server.py # prints the pairing QR page URL
8
+ python3 marrow_server.py --port 8800 --data ./data
9
+
10
+ Then in Marrow on your phone: Server → Pair with a server → scan.
11
+
12
+ What it does:
13
+ - receives Marrow's background pushes (phone app can stay closed),
14
+ - keeps a complete SQLite mirror of your verified health data,
15
+ - serves it to AI agents over MCP (Claude, Cursor, any MCP client),
16
+ 24/7 — this is the always-on companion to the app's on-device
17
+ Live mode.
18
+
19
+ Your data flows only from your phone to this machine. No cloud, no vendor,
20
+ no telemetry. Put it behind HTTPS (tailscale serve, Caddy) if you want to
21
+ reach it from outside your LAN — never port-forward it raw.
22
+
23
+ Endpoints (app token via X-HealthBridge-Token; agents via Authorization:
24
+ Bearer <mcp token> — two separate tokens, both auto-generated in --data):
25
+ GET /pair pairing page (QR) — open on a second screen, scan
26
+ POST /ping pairing test
27
+ POST /ingest sample batches from the app (idempotent, uuid upsert)
28
+ GET /status mirror summary
29
+ POST /mcp MCP Streamable HTTP (JSON-RPC 2.0)
30
+ GET /health liveness
31
+ """
32
+
33
+ import argparse
34
+ import hmac
35
+ import json
36
+ import re
37
+ import secrets
38
+ import socket
39
+ import sqlite3
40
+ from datetime import datetime, timedelta, timezone
41
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
42
+ from pathlib import Path
43
+ from urllib.parse import urlparse, parse_qs
44
+
45
+ PROTOCOL_VERSION = "2025-06-18"
46
+ MAX_BODY = 32 * 1024 * 1024
47
+
48
+ # App stream key -> Apple-native type. `scale` converts wire value back to
49
+ # Apple-native storage (the one that matters: SpO2 travels as 97, stores as
50
+ # the 0.97 fraction Apple uses).
51
+ KEYMAP = {
52
+ "step_count": ("HKQuantityTypeIdentifierStepCount", 1),
53
+ "walking_running_distance": ("HKQuantityTypeIdentifierDistanceWalkingRunning", 1),
54
+ "flights_climbed": ("HKQuantityTypeIdentifierFlightsClimbed", 1),
55
+ "active_energy": ("HKQuantityTypeIdentifierActiveEnergyBurned", 1),
56
+ "basal_energy_burned": ("HKQuantityTypeIdentifierBasalEnergyBurned", 1),
57
+ "apple_exercise_time": ("HKQuantityTypeIdentifierAppleExerciseTime", 1),
58
+ "apple_stand_time": ("HKQuantityTypeIdentifierAppleStandTime", 1),
59
+ "heart_rate": ("HKQuantityTypeIdentifierHeartRate", 1),
60
+ "resting_heart_rate": ("HKQuantityTypeIdentifierRestingHeartRate", 1),
61
+ "heart_rate_variability": ("HKQuantityTypeIdentifierHeartRateVariabilitySDNN", 1),
62
+ "respiratory_rate": ("HKQuantityTypeIdentifierRespiratoryRate", 1),
63
+ "blood_oxygen": ("HKQuantityTypeIdentifierOxygenSaturation", 0.01),
64
+ "body_mass": ("HKQuantityTypeIdentifierBodyMass", 1),
65
+ }
66
+
67
+ STAGEMAP = {
68
+ "core": "HKCategoryValueSleepAnalysisAsleepCore",
69
+ "deep": "HKCategoryValueSleepAnalysisAsleepDeep",
70
+ "rem": "HKCategoryValueSleepAnalysisAsleepREM",
71
+ "asleep": "HKCategoryValueSleepAnalysisAsleepUnspecified",
72
+ "awake": "HKCategoryValueSleepAnalysisAwake",
73
+ "in_bed": "HKCategoryValueSleepAnalysisInBed",
74
+ }
75
+
76
+ SCHEMA = """
77
+ CREATE TABLE IF NOT EXISTS records (
78
+ uuid TEXT UNIQUE,
79
+ type TEXT NOT NULL,
80
+ source_name TEXT,
81
+ source_version TEXT,
82
+ device TEXT,
83
+ unit TEXT,
84
+ creation_date TEXT,
85
+ start_date TEXT,
86
+ end_date TEXT,
87
+ value TEXT,
88
+ num REAL,
89
+ day TEXT
90
+ );
91
+ CREATE INDEX IF NOT EXISTS idx_records_type_day ON records (type, day);
92
+ CREATE INDEX IF NOT EXISTS idx_records_day ON records (day);
93
+ CREATE TABLE IF NOT EXISTS workouts (
94
+ id INTEGER PRIMARY KEY,
95
+ uuid TEXT UNIQUE,
96
+ activity_type TEXT,
97
+ duration REAL,
98
+ duration_unit TEXT,
99
+ source_name TEXT,
100
+ device TEXT,
101
+ creation_date TEXT,
102
+ start_date TEXT,
103
+ end_date TEXT,
104
+ day TEXT
105
+ );
106
+ CREATE TABLE IF NOT EXISTS ingest_log (
107
+ ts TEXT, seq INTEGER, stream TEXT, added INTEGER, deleted INTEGER
108
+ );
109
+ """
110
+
111
+ SUM_HINTS = ("StepCount", "Distance", "FlightsClimbed", "EnergyBurned",
112
+ "ExerciseTime", "StandTime", "MoveTime", "Dietary", "PushCount",
113
+ "SwimmingStrokeCount", "TimeInDaylight", "NumberOf",
114
+ "InhalerUsage", "InsulinDelivery")
115
+
116
+
117
+ def short_key(apple_type):
118
+ t = re.sub(r"^HK(Quantity|Category)TypeIdentifier", "", apple_type)
119
+ return re.sub(r"(?<!^)(?=[A-Z])", "_", t).lower()
120
+
121
+
122
+ def load_token(path):
123
+ if path.exists():
124
+ return path.read_text().strip()
125
+ tok = secrets.token_urlsafe(24)
126
+ path.write_text(tok)
127
+ path.chmod(0o600)
128
+ return tok
129
+
130
+
131
+ def lan_ip():
132
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
133
+ try:
134
+ s.connect(("10.255.255.255", 1))
135
+ return s.getsockname()[0]
136
+ except OSError:
137
+ return "127.0.0.1"
138
+ finally:
139
+ s.close()
140
+
141
+
142
+ def local_strings(iso_utc, offset_s):
143
+ """UTC instant -> device-local strings. The day must come from the
144
+ DEVICE's timezone — server-side days are the classic off-by-one."""
145
+ dt = datetime.fromisoformat(iso_utc.replace("Z", "+00:00"))
146
+ dt = dt.astimezone(timezone(timedelta(seconds=offset_s)))
147
+ return dt.strftime("%Y-%m-%d %H:%M:%S %z"), dt.strftime("%Y-%m-%d")
148
+
149
+
150
+ class Mirror:
151
+ def __init__(self, data_dir):
152
+ data_dir.mkdir(parents=True, exist_ok=True)
153
+ self.db_path = data_dir / "mirror.sqlite"
154
+ self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
155
+ self.conn.executescript(SCHEMA)
156
+ self.staging = data_dir / "staging"
157
+ self.staging.mkdir(exist_ok=True)
158
+
159
+ # ---- ingest (Marrow push protocol; idempotent on uuid) ----
160
+
161
+ def ingest(self, payload):
162
+ stream = str(payload.get("stream") or "")
163
+ hk_type = str(payload.get("hk_type") or "")
164
+ try:
165
+ native_scale = float(payload.get("native_scale") or 1)
166
+ except (TypeError, ValueError):
167
+ native_scale = 1.0
168
+ try:
169
+ offset = int(payload.get("tz_offset_seconds") or 0)
170
+ except (TypeError, ValueError):
171
+ offset = 0
172
+ added = payload.get("added") or []
173
+ deleted = payload.get("deleted") or []
174
+ if not isinstance(added, list) or not isinstance(deleted, list) \
175
+ or not all(isinstance(x, dict) for x in added):
176
+ raise ValueError("added/deleted must be lists (of objects)")
177
+
178
+ # Raw staging first — everything downstream is rebuildable.
179
+ stamp = datetime.now().strftime("%Y-%m-%d")
180
+ with open(self.staging / f"{stamp}.jsonl", "a") as fh:
181
+ fh.write(json.dumps(payload, separators=(",", ":")) + "\n")
182
+
183
+ n_add = n_del = n_ignored = 0
184
+ cur = self.conn.cursor()
185
+ for s in added:
186
+ uuid = str(s.get("uuid") or "")
187
+ start = str(s.get("start") or "")
188
+ end = str(s.get("end") or "")
189
+ if not uuid or not start or not end:
190
+ n_ignored += 1
191
+ continue
192
+ try:
193
+ start_s, day = local_strings(start, offset)
194
+ end_s, _ = local_strings(end, offset)
195
+ except ValueError:
196
+ n_ignored += 1
197
+ continue
198
+ source = str(s.get("source") or "")
199
+ device = str(s.get("device") or "")
200
+ unit = str(s.get("unit") or "")
201
+ value = s.get("value")
202
+
203
+ if stream == "workout":
204
+ cur.execute("""
205
+ INSERT INTO workouts(uuid,activity_type,duration,duration_unit,
206
+ source_name,device,start_date,end_date,day)
207
+ VALUES(?,?,?,?,?,?,?,?,?)
208
+ ON CONFLICT(uuid) DO UPDATE SET duration=excluded.duration,
209
+ start_date=excluded.start_date, end_date=excluded.end_date,
210
+ day=excluded.day
211
+ """, (uuid,
212
+ "HKWorkoutActivityType" + str(s.get("stage") or "Workout"),
213
+ value, unit, source, device, start_s, end_s, day))
214
+ n_add += 1
215
+ continue
216
+
217
+ if stream == "sleep":
218
+ row_type = "HKCategoryTypeIdentifierSleepAnalysis"
219
+ text_value = STAGEMAP.get(str(s.get("stage") or ""),
220
+ STAGEMAP["asleep"])
221
+ num = None
222
+ elif stream in KEYMAP:
223
+ row_type, scale = KEYMAP[stream]
224
+ try:
225
+ num = float(value) * scale
226
+ except (TypeError, ValueError):
227
+ n_ignored += 1
228
+ continue
229
+ text_value = repr(num)
230
+ elif hk_type.startswith("HK"):
231
+ # Generic path: payload carries its own Apple-native type +
232
+ # scale, so app streams added later land with zero server
233
+ # updates.
234
+ row_type = hk_type
235
+ try:
236
+ num = float(value) * native_scale
237
+ except (TypeError, ValueError):
238
+ n_ignored += 1
239
+ continue
240
+ text_value = repr(num)
241
+ else:
242
+ n_ignored += 1 # unknown, kept in staging; never 4xx
243
+ continue
244
+
245
+ cur.execute("""
246
+ INSERT INTO records(uuid,type,source_name,device,unit,
247
+ start_date,end_date,value,num,day)
248
+ VALUES(?,?,?,?,?,?,?,?,?,?)
249
+ ON CONFLICT(uuid) DO UPDATE SET num=excluded.num,
250
+ value=excluded.value, start_date=excluded.start_date,
251
+ end_date=excluded.end_date, day=excluded.day
252
+ """, (uuid, row_type, source, device, unit,
253
+ start_s, end_s, text_value, num, day))
254
+ n_add += 1
255
+
256
+ for u in deleted:
257
+ cur.execute("DELETE FROM records WHERE uuid=?", (str(u),))
258
+ cur.execute("DELETE FROM workouts WHERE uuid=?", (str(u),))
259
+ n_del += cur.rowcount
260
+
261
+ cur.execute("INSERT INTO ingest_log VALUES(?,?,?,?,?)",
262
+ (datetime.now().isoformat(timespec="seconds"),
263
+ payload.get("seq"), stream, n_add, n_del))
264
+ self.conn.commit()
265
+ return {"status": "ok", "added": n_add, "deleted": n_del,
266
+ "ignored": n_ignored}
267
+
268
+ def status(self):
269
+ c = self.conn
270
+ total, days, lo, hi = c.execute(
271
+ "SELECT COUNT(*), COUNT(DISTINCT day), MIN(day), MAX(day) "
272
+ "FROM records").fetchone()
273
+ last = c.execute("SELECT MAX(ts) FROM ingest_log").fetchone()[0]
274
+ return {"records": total, "days": days, "first_day": lo,
275
+ "last_day": hi, "last_ingest": last}
276
+
277
+ # ---- reads for MCP ----
278
+
279
+ def types(self):
280
+ return self.conn.execute(
281
+ "SELECT type, unit, COUNT(*), MIN(day), MAX(day) FROM records "
282
+ "GROUP BY type ORDER BY 3 DESC").fetchall()
283
+
284
+ def resolve(self, key):
285
+ for (t,) in self.conn.execute("SELECT DISTINCT type FROM records"):
286
+ if t == key or short_key(t) == key:
287
+ return t
288
+ return None
289
+
290
+ def daily(self, apple_type, days):
291
+ agg = "SUM" if any(h in apple_type for h in SUM_HINTS) else "AVG"
292
+ floor = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
293
+ return self.conn.execute(
294
+ f"SELECT day, {agg}(num) FROM records "
295
+ "WHERE type=? AND day>=? AND num IS NOT NULL "
296
+ "GROUP BY day ORDER BY day", (apple_type, floor)).fetchall()
297
+
298
+ def samples(self, apple_type, days, limit):
299
+ floor = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
300
+ return self.conn.execute(
301
+ "SELECT start_date, end_date, value, num, source_name FROM records "
302
+ "WHERE type=? AND day>=? ORDER BY start_date DESC LIMIT ?",
303
+ (apple_type, floor, limit)).fetchall()
304
+
305
+ def workouts(self, days):
306
+ floor = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
307
+ return self.conn.execute(
308
+ "SELECT activity_type, start_date, end_date, duration, "
309
+ "duration_unit, source_name FROM workouts WHERE day>=? "
310
+ "ORDER BY start_date DESC", (floor,)).fetchall()
311
+
312
+
313
+ TOOLS = [
314
+ {"name": "health_summary",
315
+ "description": "Daily values for core metrics over recent days.",
316
+ "inputSchema": {"type": "object", "properties": {
317
+ "days": {"type": "integer",
318
+ "description": "Recent days (default 3, max 30)"}}}},
319
+ {"name": "list_metrics",
320
+ "description": "Every metric in the mirror with units and coverage; "
321
+ "use the 'key' values in the other tools.",
322
+ "inputSchema": {"type": "object", "properties": {}}},
323
+ {"name": "metric_daily",
324
+ "description": "Daily aggregated values for one metric.",
325
+ "inputSchema": {"type": "object", "properties": {
326
+ "metric": {"type": "string", "description": "Metric key"},
327
+ "days": {"type": "integer",
328
+ "description": "Days back (default 30, max 400)"}},
329
+ "required": ["metric"]}},
330
+ {"name": "metric_samples",
331
+ "description": "Raw records for one metric.",
332
+ "inputSchema": {"type": "object", "properties": {
333
+ "metric": {"type": "string", "description": "Metric key"},
334
+ "days": {"type": "integer", "description": "Days back (default 7)"},
335
+ "limit": {"type": "integer",
336
+ "description": "Max records (default 200, max 1000)"}},
337
+ "required": ["metric"]}},
338
+ {"name": "workouts",
339
+ "description": "Workouts in the mirror (all sources).",
340
+ "inputSchema": {"type": "object", "properties": {
341
+ "days": {"type": "integer", "description": "Days back (default 30)"}}}},
342
+ ]
343
+
344
+ SUMMARY_KEYS = ["step_count", "active_energy_burned", "apple_exercise_time",
345
+ "resting_heart_rate", "heart_rate_variability_s_d_n_n",
346
+ "body_mass", "dietary_energy_consumed"]
347
+
348
+
349
+ def call_tool(mirror, name, args):
350
+ def clamp(key, default, cap):
351
+ try:
352
+ v = int(args.get(key, default))
353
+ except (TypeError, ValueError):
354
+ v = default
355
+ return max(1, min(v, cap))
356
+
357
+ if name == "list_metrics":
358
+ return [{"key": short_key(t), "unit": u or "", "records": n,
359
+ "first_day": lo, "latest_day": hi}
360
+ for t, u, n, lo, hi in mirror.types()]
361
+ if name == "health_summary":
362
+ days = clamp("days", 3, 30)
363
+ out = {}
364
+ for key in SUMMARY_KEYS:
365
+ t = mirror.resolve(key)
366
+ if t:
367
+ series = mirror.daily(t, days)
368
+ if series:
369
+ out[key] = {d: round(v, 2) for d, v in series}
370
+ return out
371
+ if name in ("metric_daily", "metric_samples"):
372
+ t = mirror.resolve(args.get("metric", ""))
373
+ if not t:
374
+ raise ValueError(f"unknown metric: {args.get('metric')} "
375
+ "(see list_metrics)")
376
+ if name == "metric_daily":
377
+ return {"metric": short_key(t),
378
+ "days": {d: round(v, 3)
379
+ for d, v in mirror.daily(t, clamp("days", 30, 400))}}
380
+ rows = mirror.samples(t, clamp("days", 7, 400), clamp("limit", 200, 1000))
381
+ return [{"start": s, "end": e,
382
+ "value": num if num is not None else v, "source": src}
383
+ for s, e, v, num, src in rows]
384
+ if name == "workouts":
385
+ return [{"activity": a, "start": s, "end": e, "duration": d,
386
+ "unit": u, "source": src}
387
+ for a, s, e, d, u, src in mirror.workouts(clamp("days", 30, 400))]
388
+ raise ValueError(f"unknown tool: {name}")
389
+
390
+
391
+ def pair_page(pairing):
392
+ info = json.dumps(pairing)
393
+ try:
394
+ import segno
395
+ import base64
396
+ import io
397
+ buf = io.BytesIO()
398
+ segno.make(info).save(buf, kind="png", scale=8)
399
+ img = base64.b64encode(buf.getvalue()).decode()
400
+ qr = f'<img src="data:image/png;base64,{img}" alt="pairing QR">'
401
+ except ImportError:
402
+ qr = "<p>(install <code>segno</code> for a QR; manual JSON below)</p>"
403
+ return f"""<!doctype html><meta charset="utf-8">
404
+ <title>Pair Marrow</title>
405
+ <body style="font-family:-apple-system,sans-serif;max-width:32em;margin:3em auto">
406
+ <h2>Pair Marrow with this server</h2>
407
+ <p>On your iPhone: <b>Marrow → Server → Pair with a server</b>, then scan:</p>
408
+ {qr}
409
+ <p style="color:#666">Manual fallback — pairing JSON:</p>
410
+ <pre style="background:#f4f4f2;padding:1em;overflow:auto">{info}</pre>
411
+ </body>""".encode()
412
+
413
+
414
+ def make_handler(mirror, app_token, mcp_token, pairing):
415
+ class Handler(BaseHTTPRequestHandler):
416
+ server_version = "Marrow/1.0"
417
+ protocol_version = "HTTP/1.1"
418
+
419
+ def log_message(self, *a):
420
+ pass
421
+
422
+ def _json(self, code, obj=None):
423
+ body = json.dumps(obj).encode() if obj is not None else b""
424
+ self.send_response(code)
425
+ self.send_header("Content-Type", "application/json")
426
+ self.send_header("Content-Length", str(len(body)))
427
+ self.end_headers()
428
+ self.wfile.write(body)
429
+
430
+ def _app_authed(self):
431
+ q = parse_qs(urlparse(self.path).query)
432
+ supplied = (self.headers.get("X-HealthBridge-Token")
433
+ or (q.get("token") or [""])[0])
434
+ return hmac.compare_digest(supplied, app_token)
435
+
436
+ def _mcp_authed(self):
437
+ return hmac.compare_digest(self.headers.get("Authorization", ""),
438
+ f"Bearer {mcp_token}")
439
+
440
+ def do_GET(self):
441
+ path = urlparse(self.path).path.rstrip("/")
442
+ if path in ("", "/health"):
443
+ return self._json(200, {"server": "marrow", "ok": True})
444
+ if not self._app_authed():
445
+ return self._json(401, {"error": "bad or missing token"})
446
+ if path == "/pair":
447
+ body = pair_page(pairing)
448
+ self.send_response(200)
449
+ self.send_header("Content-Type", "text/html; charset=utf-8")
450
+ self.send_header("Content-Length", str(len(body)))
451
+ self.end_headers()
452
+ self.wfile.write(body)
453
+ return
454
+ if path == "/status":
455
+ return self._json(200, mirror.status())
456
+ return self._json(404, {"error": "not found"})
457
+
458
+ def do_POST(self):
459
+ path = urlparse(self.path).path.rstrip("/")
460
+ length = int(self.headers.get("Content-Length") or 0)
461
+ if length <= 0 or length > MAX_BODY:
462
+ return self._json(411 if length <= 0 else 413,
463
+ {"error": "bad length"})
464
+ raw = self.rfile.read(length)
465
+
466
+ if path == "/mcp":
467
+ if not self._mcp_authed():
468
+ return self._json(401, {"error": "bad bearer token"})
469
+ return self.mcp(raw)
470
+
471
+ if not self._app_authed():
472
+ return self._json(401, {"error": "bad or missing token"})
473
+ if path == "/ping":
474
+ return self._json(200, {"status": "ok", "server": "marrow"})
475
+ if path == "/ingest":
476
+ try:
477
+ payload = json.loads(raw)
478
+ assert isinstance(payload, dict)
479
+ except Exception:
480
+ return self._json(400, {"error": "body must be JSON object"})
481
+ try:
482
+ return self._json(200, mirror.ingest(payload))
483
+ except ValueError as exc:
484
+ # Malformed shape = client bug: 4xx so a broken payload
485
+ # can't wedge the app's outbox in an infinite retry.
486
+ return self._json(400, {"error": str(exc)})
487
+ return self._json(404, {"error": "not found"})
488
+
489
+ def mcp(self, raw):
490
+ try:
491
+ msg = json.loads(raw)
492
+ except Exception:
493
+ return self._json(400, {"error": "not JSON"})
494
+ method = msg.get("method", "")
495
+ if "id" not in msg:
496
+ return self._json(202)
497
+ rid = msg["id"]
498
+ params = msg.get("params") or {}
499
+ try:
500
+ if method == "initialize":
501
+ result = {"protocolVersion": PROTOCOL_VERSION,
502
+ "capabilities": {"tools": {}},
503
+ "serverInfo": {"name": "marrow-selfhost",
504
+ "title": "Marrow (self-hosted)",
505
+ "version": "1.0"}}
506
+ elif method == "ping":
507
+ result = {}
508
+ elif method == "tools/list":
509
+ result = {"tools": TOOLS}
510
+ elif method == "tools/call":
511
+ payload = call_tool(mirror, params.get("name", ""),
512
+ params.get("arguments") or {})
513
+ result = {"content": [{"type": "text",
514
+ "text": json.dumps(payload,
515
+ sort_keys=True)}],
516
+ "isError": False}
517
+ else:
518
+ return self._json(200, {"jsonrpc": "2.0", "id": rid,
519
+ "error": {"code": -32601,
520
+ "message": f"no {method}"}})
521
+ except Exception as e:
522
+ result = {"content": [{"type": "text", "text": f"Error: {e}"}],
523
+ "isError": True}
524
+ return self._json(200, {"jsonrpc": "2.0", "id": rid,
525
+ "result": result})
526
+
527
+ return Handler
528
+
529
+
530
+ def main():
531
+ ap = argparse.ArgumentParser(description=__doc__)
532
+ ap.add_argument("--port", type=int, default=8800)
533
+ ap.add_argument("--data", default="./data",
534
+ help="data directory (mirror + tokens)")
535
+ ap.add_argument("--url", default=None,
536
+ help="advertised base URL for pairing "
537
+ "(default http://<lan-ip>:<port>)")
538
+ args = ap.parse_args()
539
+
540
+ data = Path(args.data).resolve()
541
+ data.mkdir(parents=True, exist_ok=True)
542
+ app_token = load_token(data / ".app-token")
543
+ mcp_token = load_token(data / ".mcp-token")
544
+ mirror = Mirror(data)
545
+ base = args.url or f"http://{lan_ip()}:{args.port}"
546
+ pairing = {"url": base, "token": app_token, "name": "Self-hosted server"}
547
+
548
+ print(f"Marrow self-hosted server on 0.0.0.0:{args.port}")
549
+ print(f" pair: {base}/pair?token={app_token}")
550
+ print(f" agents: {base}/mcp (Authorization: Bearer {mcp_token})")
551
+ ThreadingHTTPServer(("0.0.0.0", args.port),
552
+ make_handler(mirror, app_token, mcp_token,
553
+ pairing)).serve_forever()
554
+
555
+
556
+ if __name__ == "__main__":
557
+ main()
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "marrow-mcp"
7
+ version = "0.2.0"
8
+ description = "Self-hosted mirror and MCP server for Marrow, giving AI agents read-only access to your own Apple Health data"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Skeleton Army" }]
13
+ keywords = ["mcp", "model-context-protocol", "apple-health", "healthkit", "quantified-self", "agents"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: End Users/Desktop",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Scientific/Engineering :: Medical Science Apps.",
20
+ ]
21
+ # Standard library only. segno is optional and only draws the pairing QR in
22
+ # the terminal; the pairing page renders one either way.
23
+ dependencies = []
24
+
25
+ [project.optional-dependencies]
26
+ qr = ["segno>=1.6"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://skeletonarmy.tech/"
30
+ Documentation = "https://skeletonarmy.tech/mcp"
31
+ Source = "https://github.com/DukeAidanHall/marrow-mcp"
32
+
33
+ [project.scripts]
34
+ marrow-server = "marrow_server:main"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ include = ["marrow_server.py"]
38
+
39
+ # Whitelist rather than exclude: the build venv lives in the repo and hatch
40
+ # chokes on its absolute symlinks if the sdist tries to sweep it in.
41
+ [tool.hatch.build.targets.sdist]
42
+ include = [
43
+ "marrow_server.py",
44
+ "server.json",
45
+ "Dockerfile",
46
+ "docker-compose.yml",
47
+ "README.md",
48
+ "LICENSE",
49
+ ]
@@ -0,0 +1,27 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
3
+ "name": "io.github.DukeAidanHall/marrow",
4
+ "description": "Read-only MCP access to your own Apple Health data: 174 metrics, workouts and a food diary, mirrored from the Marrow iOS app onto hardware you own.",
5
+ "version": "0.2.0",
6
+ "repository": {
7
+ "url": "https://github.com/DukeAidanHall/marrow-mcp",
8
+ "source": "github"
9
+ },
10
+ "websiteUrl": "https://skeletonarmy.tech/mcp",
11
+ "packages": [
12
+ {
13
+ "registryType": "pypi",
14
+ "identifier": "marrow-mcp",
15
+ "version": "0.2.0",
16
+ "transport": { "type": "streamable-http", "url": "http://localhost:8800/mcp" },
17
+ "runtimeHint": "python",
18
+ "environmentVariables": [
19
+ {
20
+ "name": "MARROW_DATA",
21
+ "description": "Directory holding the SQLite mirror and the two generated tokens. Defaults to ./data.",
22
+ "isRequired": false
23
+ }
24
+ ]
25
+ }
26
+ ]
27
+ }