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.
- marrow_mcp-0.2.0/.gitignore +9 -0
- marrow_mcp-0.2.0/Dockerfile +7 -0
- marrow_mcp-0.2.0/LICENSE +21 -0
- marrow_mcp-0.2.0/PKG-INFO +158 -0
- marrow_mcp-0.2.0/README.md +117 -0
- marrow_mcp-0.2.0/docker-compose.yml +8 -0
- marrow_mcp-0.2.0/marrow_server.py +557 -0
- marrow_mcp-0.2.0/pyproject.toml +49 -0
- marrow_mcp-0.2.0/server.json +27 -0
marrow_mcp-0.2.0/LICENSE
ADDED
|
@@ -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,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
|
+
}
|