getbased-mcp 0.2.4__tar.gz → 0.2.6__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,20 @@
1
+ GNU AFFERO GENERAL PUBLIC LICENSE
2
+ Version 3, 19 November 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+ Preamble
9
+
10
+ The GNU Affero General Public License is a free, copyleft license for
11
+ software and other kinds of works, specifically designed to ensure
12
+ cooperation with the community in the case of network server software.
13
+
14
+ The licenses for most software and other practical works are designed
15
+ to take away your freedom to share and change the works. By contrast,
16
+ our General Public Licenses are intended to guarantee your freedom to
17
+ share and change all versions of a program--to make sure it remains free
18
+ software for all its users.
19
+
20
+ For the full license text, see <https://www.gnu.org/licenses/agpl-3.0.txt>
@@ -1,13 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: getbased-mcp
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: MCP server for querying blood work data and knowledge base from getbased
5
- License-Expression: GPL-3.0-only
5
+ License-Expression: AGPL-3.0-or-later
6
6
  Requires-Python: >=3.10
7
7
  Description-Content-Type: text/markdown
8
8
  License-File: LICENSE
9
9
  Requires-Dist: mcp>=1.0.0
10
10
  Requires-Dist: httpx>=0.27
11
+ Requires-Dist: cryptography>=42.0
11
12
  Provides-Extra: test
12
13
  Requires-Dist: pytest>=8.0; extra == "test"
13
14
  Requires-Dist: pytest-asyncio>=0.23; extra == "test"
@@ -25,11 +26,13 @@ An [MCP](https://modelcontextprotocol.io) server that exposes blood work data an
25
26
  ```
26
27
  getbased (browser)
27
28
  ├── your data, your mnemonic
28
- ├── generates a read-only token
29
- └── pushes lab context to sync gateway on every save
29
+ ├── generates a read-only relay token
30
+ ├── generates a separate Agent Context encryption key
31
+ ├── encrypts the rendered agent context with that context key
32
+ └── pushes ciphertext to sync gateway on every save
30
33
 
31
34
  Sync Gateway (sync.getbased.health/api/context)
32
- └── stores context text behind token auth
35
+ └── stores encrypted context behind token auth; it cannot read the plaintext
33
36
 
34
37
  RAG Server (localhost, optional)
35
38
  ├── Vector database with embedded chunks
@@ -42,7 +45,7 @@ This MCP Server (on your machine)
42
45
  └── exposes everything as tools to any MCP client
43
46
  ```
44
47
 
45
- Your mnemonic never leaves your browser. The MCP server receives the same lab context text the getbased AI chat uses — not raw data.
48
+ Your mnemonic never leaves your browser. The sync gateway receives only an end-to-end encrypted agent-context envelope; this MCP decrypts it locally with `GETBASED_AGENT_CONTEXT_KEY` and then exposes the same lab context text the getbased AI chat uses — not raw data. `GETBASED_TOKEN` is only the relay bearer token.
46
49
 
47
50
  ## Tools
48
51
 
@@ -116,9 +119,12 @@ The gateway stores context per profile ID. To work with multiple profiles:
116
119
 
117
120
  ## Setup
118
121
 
119
- ### 1. Enable messenger access in getbased
122
+ ### 1. Enable Agent Access in getbased
120
123
 
121
- Go to **Settings > Data > Messenger Access** and toggle it on. Copy the read-only token.
124
+ Go to **Settings > Data > Agent Access** and toggle it on. Copy both values:
125
+
126
+ - **Read-only token** → `GETBASED_TOKEN` for relay authorization
127
+ - **Context encryption key** → `GETBASED_AGENT_CONTEXT_KEY` for local decryption
122
128
 
123
129
  ### 2. Set up a RAG server (optional — for knowledge_search)
124
130
 
@@ -152,7 +158,8 @@ Add to your MCP config (`~/.claude/claude_desktop_config.json` or similar):
152
158
  "command": "python3",
153
159
  "args": ["/path/to/getbased_mcp.py"],
154
160
  "env": {
155
- "GETBASED_TOKEN": "your-token-here"
161
+ "GETBASED_TOKEN": "your-token-here",
162
+ "GETBASED_AGENT_CONTEXT_KEY": "your-context-key-here"
156
163
  }
157
164
  }
158
165
  }
@@ -167,7 +174,7 @@ hermes mcp add getbased \
167
174
  --args /path/to/getbased_mcp.py
168
175
  ```
169
176
 
170
- Then set `GETBASED_TOKEN` in `~/.hermes/.env` or in the MCP server's `env` config in `config.yaml`:
177
+ Then set `GETBASED_TOKEN` and `GETBASED_AGENT_CONTEXT_KEY` in `~/.hermes/.env` or in the MCP server's `env` config in `config.yaml`:
171
178
 
172
179
  ```yaml
173
180
  mcp_servers:
@@ -176,6 +183,7 @@ mcp_servers:
176
183
  args: [/path/to/getbased_mcp.py]
177
184
  env:
178
185
  GETBASED_TOKEN: your-token-here
186
+ GETBASED_AGENT_CONTEXT_KEY: your-context-key-here
179
187
  ```
180
188
 
181
189
  ### 4. Use it
@@ -191,7 +199,8 @@ Ask about your labs in any connected conversation:
191
199
 
192
200
  | Variable | Required | Description |
193
201
  |---|---|---|
194
- | `GETBASED_TOKEN` | Yes | Read-only token from getbased Settings > Data > Messenger Access |
202
+ | `GETBASED_TOKEN` | Yes | Read-only bearer token from getbased Settings > Data > Agent Access; authorizes fetches from the context gateway |
203
+ | `GETBASED_AGENT_CONTEXT_KEY` | Yes for encrypted Agent Access payloads | Context encryption key from getbased Settings > Data > Agent Access; decrypts the relay payload locally in this MCP |
195
204
  | `GETBASED_GATEWAY` | No | Context gateway URL (default: `https://sync.getbased.health`) |
196
205
  | `LENS_URL` | No | RAG server URL (default: `http://localhost:8322`). Overrides `LENS_PORT` |
197
206
  | `LENS_PORT` | No | RAG server port, only used to build default `LENS_URL` (default: `8322`) |
@@ -241,8 +250,8 @@ That's expected — they're independent. Blood work tools talk to the sync gatew
241
250
 
242
251
  ## Related projects
243
252
 
244
- - **[getbased](https://github.com/elkimek/get-based)** — the health dashboard. This MCP reads the same lab context the in-app AI chat uses, and queries the same Knowledge Source endpoint configured in Settings → AI → Custom Knowledge Source. The [endpoint contract](https://github.com/elkimek/get-based/blob/main/docs/guide/interpretive-lens.md#for-developers-endpoint-contract) is shared — one server backs both the app and this MCP.
253
+ - **[getbased](https://github.com/elkimek/get-based)** — the health dashboard. This MCP reads the same lab context the in-app AI chat uses, and queries the same Knowledge Source endpoint configured in Settings → AI → Custom Knowledge Source. The [endpoint contract](https://github.com/elkimek/get-based/blob/main/dev-docs/lens-endpoint-contract.md) is shared — one server backs both the app and this MCP.
245
254
 
246
255
  ## License
247
256
 
248
- GPL-3.0
257
+ AGPL-3.0-or-later
@@ -1,19 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: getbased-mcp
3
- Version: 0.2.4
4
- Summary: MCP server for querying blood work data and knowledge base from getbased
5
- License-Expression: GPL-3.0-only
6
- Requires-Python: >=3.10
7
- Description-Content-Type: text/markdown
8
- License-File: LICENSE
9
- Requires-Dist: mcp>=1.0.0
10
- Requires-Dist: httpx>=0.27
11
- Provides-Extra: test
12
- Requires-Dist: pytest>=8.0; extra == "test"
13
- Requires-Dist: pytest-asyncio>=0.23; extra == "test"
14
- Requires-Dist: respx>=0.21; extra == "test"
15
- Dynamic: license-file
16
-
17
1
  # getbased MCP Server
18
2
 
19
3
  An [MCP](https://modelcontextprotocol.io) server that exposes blood work data and an optional RAG knowledge base from [getbased](https://getbased.health) as tools. Works with any MCP-compatible client (Claude Code, Hermes, Claude Desktop, etc.).
@@ -25,11 +9,13 @@ An [MCP](https://modelcontextprotocol.io) server that exposes blood work data an
25
9
  ```
26
10
  getbased (browser)
27
11
  ├── your data, your mnemonic
28
- ├── generates a read-only token
29
- └── pushes lab context to sync gateway on every save
12
+ ├── generates a read-only relay token
13
+ ├── generates a separate Agent Context encryption key
14
+ ├── encrypts the rendered agent context with that context key
15
+ └── pushes ciphertext to sync gateway on every save
30
16
 
31
17
  Sync Gateway (sync.getbased.health/api/context)
32
- └── stores context text behind token auth
18
+ └── stores encrypted context behind token auth; it cannot read the plaintext
33
19
 
34
20
  RAG Server (localhost, optional)
35
21
  ├── Vector database with embedded chunks
@@ -42,7 +28,7 @@ This MCP Server (on your machine)
42
28
  └── exposes everything as tools to any MCP client
43
29
  ```
44
30
 
45
- Your mnemonic never leaves your browser. The MCP server receives the same lab context text the getbased AI chat uses — not raw data.
31
+ Your mnemonic never leaves your browser. The sync gateway receives only an end-to-end encrypted agent-context envelope; this MCP decrypts it locally with `GETBASED_AGENT_CONTEXT_KEY` and then exposes the same lab context text the getbased AI chat uses — not raw data. `GETBASED_TOKEN` is only the relay bearer token.
46
32
 
47
33
  ## Tools
48
34
 
@@ -116,9 +102,12 @@ The gateway stores context per profile ID. To work with multiple profiles:
116
102
 
117
103
  ## Setup
118
104
 
119
- ### 1. Enable messenger access in getbased
105
+ ### 1. Enable Agent Access in getbased
106
+
107
+ Go to **Settings > Data > Agent Access** and toggle it on. Copy both values:
120
108
 
121
- Go to **Settings > Data > Messenger Access** and toggle it on. Copy the read-only token.
109
+ - **Read-only token** `GETBASED_TOKEN` for relay authorization
110
+ - **Context encryption key** → `GETBASED_AGENT_CONTEXT_KEY` for local decryption
122
111
 
123
112
  ### 2. Set up a RAG server (optional — for knowledge_search)
124
113
 
@@ -152,7 +141,8 @@ Add to your MCP config (`~/.claude/claude_desktop_config.json` or similar):
152
141
  "command": "python3",
153
142
  "args": ["/path/to/getbased_mcp.py"],
154
143
  "env": {
155
- "GETBASED_TOKEN": "your-token-here"
144
+ "GETBASED_TOKEN": "your-token-here",
145
+ "GETBASED_AGENT_CONTEXT_KEY": "your-context-key-here"
156
146
  }
157
147
  }
158
148
  }
@@ -167,7 +157,7 @@ hermes mcp add getbased \
167
157
  --args /path/to/getbased_mcp.py
168
158
  ```
169
159
 
170
- Then set `GETBASED_TOKEN` in `~/.hermes/.env` or in the MCP server's `env` config in `config.yaml`:
160
+ Then set `GETBASED_TOKEN` and `GETBASED_AGENT_CONTEXT_KEY` in `~/.hermes/.env` or in the MCP server's `env` config in `config.yaml`:
171
161
 
172
162
  ```yaml
173
163
  mcp_servers:
@@ -176,6 +166,7 @@ mcp_servers:
176
166
  args: [/path/to/getbased_mcp.py]
177
167
  env:
178
168
  GETBASED_TOKEN: your-token-here
169
+ GETBASED_AGENT_CONTEXT_KEY: your-context-key-here
179
170
  ```
180
171
 
181
172
  ### 4. Use it
@@ -191,7 +182,8 @@ Ask about your labs in any connected conversation:
191
182
 
192
183
  | Variable | Required | Description |
193
184
  |---|---|---|
194
- | `GETBASED_TOKEN` | Yes | Read-only token from getbased Settings > Data > Messenger Access |
185
+ | `GETBASED_TOKEN` | Yes | Read-only bearer token from getbased Settings > Data > Agent Access; authorizes fetches from the context gateway |
186
+ | `GETBASED_AGENT_CONTEXT_KEY` | Yes for encrypted Agent Access payloads | Context encryption key from getbased Settings > Data > Agent Access; decrypts the relay payload locally in this MCP |
195
187
  | `GETBASED_GATEWAY` | No | Context gateway URL (default: `https://sync.getbased.health`) |
196
188
  | `LENS_URL` | No | RAG server URL (default: `http://localhost:8322`). Overrides `LENS_PORT` |
197
189
  | `LENS_PORT` | No | RAG server port, only used to build default `LENS_URL` (default: `8322`) |
@@ -241,8 +233,8 @@ That's expected — they're independent. Blood work tools talk to the sync gatew
241
233
 
242
234
  ## Related projects
243
235
 
244
- - **[getbased](https://github.com/elkimek/get-based)** — the health dashboard. This MCP reads the same lab context the in-app AI chat uses, and queries the same Knowledge Source endpoint configured in Settings → AI → Custom Knowledge Source. The [endpoint contract](https://github.com/elkimek/get-based/blob/main/docs/guide/interpretive-lens.md#for-developers-endpoint-contract) is shared — one server backs both the app and this MCP.
236
+ - **[getbased](https://github.com/elkimek/get-based)** — the health dashboard. This MCP reads the same lab context the in-app AI chat uses, and queries the same Knowledge Source endpoint configured in Settings → AI → Custom Knowledge Source. The [endpoint contract](https://github.com/elkimek/get-based/blob/main/dev-docs/lens-endpoint-contract.md) is shared — one server backs both the app and this MCP.
245
237
 
246
238
  ## License
247
239
 
248
- GPL-3.0
240
+ AGPL-3.0-or-later
@@ -1,3 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: getbased-mcp
3
+ Version: 0.2.6
4
+ Summary: MCP server for querying blood work data and knowledge base from getbased
5
+ License-Expression: AGPL-3.0-or-later
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: mcp>=1.0.0
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: cryptography>=42.0
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest>=8.0; extra == "test"
14
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
15
+ Requires-Dist: respx>=0.21; extra == "test"
16
+ Dynamic: license-file
17
+
1
18
  # getbased MCP Server
2
19
 
3
20
  An [MCP](https://modelcontextprotocol.io) server that exposes blood work data and an optional RAG knowledge base from [getbased](https://getbased.health) as tools. Works with any MCP-compatible client (Claude Code, Hermes, Claude Desktop, etc.).
@@ -9,11 +26,13 @@ An [MCP](https://modelcontextprotocol.io) server that exposes blood work data an
9
26
  ```
10
27
  getbased (browser)
11
28
  ├── your data, your mnemonic
12
- ├── generates a read-only token
13
- └── pushes lab context to sync gateway on every save
29
+ ├── generates a read-only relay token
30
+ ├── generates a separate Agent Context encryption key
31
+ ├── encrypts the rendered agent context with that context key
32
+ └── pushes ciphertext to sync gateway on every save
14
33
 
15
34
  Sync Gateway (sync.getbased.health/api/context)
16
- └── stores context text behind token auth
35
+ └── stores encrypted context behind token auth; it cannot read the plaintext
17
36
 
18
37
  RAG Server (localhost, optional)
19
38
  ├── Vector database with embedded chunks
@@ -26,7 +45,7 @@ This MCP Server (on your machine)
26
45
  └── exposes everything as tools to any MCP client
27
46
  ```
28
47
 
29
- Your mnemonic never leaves your browser. The MCP server receives the same lab context text the getbased AI chat uses — not raw data.
48
+ Your mnemonic never leaves your browser. The sync gateway receives only an end-to-end encrypted agent-context envelope; this MCP decrypts it locally with `GETBASED_AGENT_CONTEXT_KEY` and then exposes the same lab context text the getbased AI chat uses — not raw data. `GETBASED_TOKEN` is only the relay bearer token.
30
49
 
31
50
  ## Tools
32
51
 
@@ -100,9 +119,12 @@ The gateway stores context per profile ID. To work with multiple profiles:
100
119
 
101
120
  ## Setup
102
121
 
103
- ### 1. Enable messenger access in getbased
122
+ ### 1. Enable Agent Access in getbased
123
+
124
+ Go to **Settings > Data > Agent Access** and toggle it on. Copy both values:
104
125
 
105
- Go to **Settings > Data > Messenger Access** and toggle it on. Copy the read-only token.
126
+ - **Read-only token** `GETBASED_TOKEN` for relay authorization
127
+ - **Context encryption key** → `GETBASED_AGENT_CONTEXT_KEY` for local decryption
106
128
 
107
129
  ### 2. Set up a RAG server (optional — for knowledge_search)
108
130
 
@@ -136,7 +158,8 @@ Add to your MCP config (`~/.claude/claude_desktop_config.json` or similar):
136
158
  "command": "python3",
137
159
  "args": ["/path/to/getbased_mcp.py"],
138
160
  "env": {
139
- "GETBASED_TOKEN": "your-token-here"
161
+ "GETBASED_TOKEN": "your-token-here",
162
+ "GETBASED_AGENT_CONTEXT_KEY": "your-context-key-here"
140
163
  }
141
164
  }
142
165
  }
@@ -151,7 +174,7 @@ hermes mcp add getbased \
151
174
  --args /path/to/getbased_mcp.py
152
175
  ```
153
176
 
154
- Then set `GETBASED_TOKEN` in `~/.hermes/.env` or in the MCP server's `env` config in `config.yaml`:
177
+ Then set `GETBASED_TOKEN` and `GETBASED_AGENT_CONTEXT_KEY` in `~/.hermes/.env` or in the MCP server's `env` config in `config.yaml`:
155
178
 
156
179
  ```yaml
157
180
  mcp_servers:
@@ -160,6 +183,7 @@ mcp_servers:
160
183
  args: [/path/to/getbased_mcp.py]
161
184
  env:
162
185
  GETBASED_TOKEN: your-token-here
186
+ GETBASED_AGENT_CONTEXT_KEY: your-context-key-here
163
187
  ```
164
188
 
165
189
  ### 4. Use it
@@ -175,7 +199,8 @@ Ask about your labs in any connected conversation:
175
199
 
176
200
  | Variable | Required | Description |
177
201
  |---|---|---|
178
- | `GETBASED_TOKEN` | Yes | Read-only token from getbased Settings > Data > Messenger Access |
202
+ | `GETBASED_TOKEN` | Yes | Read-only bearer token from getbased Settings > Data > Agent Access; authorizes fetches from the context gateway |
203
+ | `GETBASED_AGENT_CONTEXT_KEY` | Yes for encrypted Agent Access payloads | Context encryption key from getbased Settings > Data > Agent Access; decrypts the relay payload locally in this MCP |
179
204
  | `GETBASED_GATEWAY` | No | Context gateway URL (default: `https://sync.getbased.health`) |
180
205
  | `LENS_URL` | No | RAG server URL (default: `http://localhost:8322`). Overrides `LENS_PORT` |
181
206
  | `LENS_PORT` | No | RAG server port, only used to build default `LENS_URL` (default: `8322`) |
@@ -225,8 +250,8 @@ That's expected — they're independent. Blood work tools talk to the sync gatew
225
250
 
226
251
  ## Related projects
227
252
 
228
- - **[getbased](https://github.com/elkimek/get-based)** — the health dashboard. This MCP reads the same lab context the in-app AI chat uses, and queries the same Knowledge Source endpoint configured in Settings → AI → Custom Knowledge Source. The [endpoint contract](https://github.com/elkimek/get-based/blob/main/docs/guide/interpretive-lens.md#for-developers-endpoint-contract) is shared — one server backs both the app and this MCP.
253
+ - **[getbased](https://github.com/elkimek/get-based)** — the health dashboard. This MCP reads the same lab context the in-app AI chat uses, and queries the same Knowledge Source endpoint configured in Settings → AI → Custom Knowledge Source. The [endpoint contract](https://github.com/elkimek/get-based/blob/main/dev-docs/lens-endpoint-contract.md) is shared — one server backs both the app and this MCP.
229
254
 
230
255
  ## License
231
256
 
232
- GPL-3.0
257
+ AGPL-3.0-or-later
@@ -1,5 +1,6 @@
1
1
  mcp>=1.0.0
2
2
  httpx>=0.27
3
+ cryptography>=42.0
3
4
 
4
5
  [test]
5
6
  pytest>=8.0
@@ -2,7 +2,7 @@
2
2
  """getbased MCP server — exposes blood work data and knowledge base search as tools.
3
3
 
4
4
  Architecture:
5
- getbased (browser) → sync gateway → this MCP → your AI client
5
+ getbased (browser encrypts context) → sync gateway → this MCP decrypts → AI client
6
6
 
7
7
  Lens RAG server (Qdrant + BGE-M3)
8
8
 
@@ -11,7 +11,10 @@ Knowledge base queries go through the Lens RAG server (separate process).
11
11
  No models are loaded in this process — everything is HTTP.
12
12
  """
13
13
 
14
+ import base64
15
+ import binascii
14
16
  import functools
17
+ import hashlib
15
18
  import json
16
19
  import logging
17
20
  import os
@@ -19,6 +22,10 @@ import re
19
22
  import time
20
23
 
21
24
  import httpx
25
+ from cryptography.exceptions import InvalidTag
26
+ from cryptography.hazmat.primitives import hashes
27
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
28
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
22
29
  from mcp.server.fastmcp import FastMCP
23
30
 
24
31
 
@@ -63,7 +70,10 @@ mcp = FastMCP("getbased")
63
70
 
64
71
  # ── Config ───────────────────────────────────────────────────────────
65
72
  TOKEN = os.environ.get("GETBASED_TOKEN", "")
73
+ AGENT_CONTEXT_KEY = os.environ.get("GETBASED_AGENT_CONTEXT_KEY", "")
66
74
  GATEWAY = os.environ.get("GETBASED_GATEWAY", "https://sync.getbased.health")
75
+ AGENT_CONTEXT_V1_KDF_INFO = b"getbased-agent-access-context-v1"
76
+ AGENT_CONTEXT_AAD_PREFIX = b"getbased-agent-context-v2"
67
77
 
68
78
  LENS_URL = os.environ.get("LENS_URL", f"http://localhost:{os.environ.get('LENS_PORT', '8322')}")
69
79
 
@@ -178,8 +188,134 @@ def _instrumented(label: str):
178
188
 
179
189
  # ── Helpers ──────────────────────────────────────────────────────────
180
190
 
181
- async def _fetch_context(profile: str = "") -> dict:
182
- """Fetch formatted lab context from the getbased sync gateway."""
191
+ def _token_bytes(token: str) -> bytes:
192
+ token = (token or "").strip()
193
+ if re.fullmatch(r"[0-9a-fA-F]{64}", token):
194
+ try:
195
+ return bytes.fromhex(token)
196
+ except ValueError:
197
+ pass
198
+ return token.encode("utf-8")
199
+
200
+
201
+ def _decode_agent_context_key(value: str) -> bytes:
202
+ key = (value or "").strip()
203
+ if not key:
204
+ raise ValueError("GETBASED_AGENT_CONTEXT_KEY not set")
205
+ if key.startswith("gbctx_v1_"):
206
+ key = key[len("gbctx_v1_"):]
207
+ padded = key + "=" * (-len(key) % 4)
208
+ try:
209
+ raw = base64.urlsafe_b64decode(padded.encode("ascii"))
210
+ except (binascii.Error, UnicodeEncodeError) as e:
211
+ raise ValueError("GETBASED_AGENT_CONTEXT_KEY is not valid base64url") from e
212
+ if len(raw) != 32:
213
+ raise ValueError("GETBASED_AGENT_CONTEXT_KEY must decode to 32 bytes")
214
+ return raw
215
+
216
+
217
+ def _agent_context_key_id(raw_key: bytes) -> str:
218
+ return base64.urlsafe_b64encode(hashlib.sha256(raw_key).digest()[:12]).decode("ascii").rstrip("=")
219
+
220
+
221
+ def _b64decode_required(value: object, field: str) -> bytes:
222
+ if not isinstance(value, str) or not value:
223
+ raise ValueError(f"encrypted context missing {field}")
224
+ try:
225
+ return base64.b64decode(value, validate=True)
226
+ except binascii.Error as e:
227
+ raise ValueError(f"encrypted context has invalid base64 in {field}") from e
228
+
229
+
230
+ def _decrypt_agent_context(envelope: dict, profile_id: str) -> str:
231
+ """Decrypt a browser-produced Agent Access context envelope.
232
+
233
+ v2 separates relay authorization from content secrecy: GETBASED_TOKEN
234
+ authorizes the HTTPS fetch, while GETBASED_AGENT_CONTEXT_KEY decrypts the
235
+ AES-256-GCM payload locally. v1 token-derived envelopes remain readable as
236
+ a temporary rollout fallback.
237
+ """
238
+ if not isinstance(envelope, dict):
239
+ raise ValueError("encrypted context envelope is invalid")
240
+ version = envelope.get("version")
241
+ if envelope.get("alg") != "AES-256-GCM":
242
+ raise ValueError("unsupported encrypted context crypto parameters")
243
+
244
+ iv = _b64decode_required(envelope.get("iv"), "iv")
245
+ ciphertext = _b64decode_required(envelope.get("ciphertext"), "ciphertext")
246
+
247
+ if version == 2:
248
+ if envelope.get("keyDerivation") != "raw-256-bit-key":
249
+ raise ValueError("unsupported encrypted context crypto parameters")
250
+ raw_key = _decode_agent_context_key(AGENT_CONTEXT_KEY)
251
+ key_id = envelope.get("keyId")
252
+ if key_id and key_id != _agent_context_key_id(raw_key):
253
+ raise ValueError("encrypted context could not be decrypted with this Agent Context key")
254
+ aad = AGENT_CONTEXT_AAD_PREFIX + b":" + (profile_id or "default").encode("utf-8")
255
+ failure = "encrypted context could not be decrypted with this Agent Context key"
256
+ elif version == 1:
257
+ if not TOKEN:
258
+ raise ValueError("GETBASED_TOKEN not set")
259
+ if envelope.get("kdf") != "HKDF-SHA-256":
260
+ raise ValueError("unsupported encrypted context crypto parameters")
261
+ salt = _b64decode_required(envelope.get("salt"), "salt")
262
+ raw_key = HKDF(
263
+ algorithm=hashes.SHA256(),
264
+ length=32,
265
+ salt=salt,
266
+ info=AGENT_CONTEXT_V1_KDF_INFO,
267
+ ).derive(_token_bytes(TOKEN))
268
+ aad = AGENT_CONTEXT_V1_KDF_INFO + b":" + (profile_id or "default").encode("utf-8")
269
+ failure = "encrypted context could not be decrypted with this Agent Access token"
270
+ else:
271
+ raise ValueError("unsupported encrypted context version")
272
+
273
+ try:
274
+ plaintext = AESGCM(raw_key).decrypt(iv, ciphertext, aad)
275
+ return plaintext.decode("utf-8")
276
+ except InvalidTag as e:
277
+ raise ValueError(failure) from e
278
+ except UnicodeDecodeError as e:
279
+ raise ValueError("encrypted context decrypted to invalid UTF-8") from e
280
+
281
+
282
+ def _decode_context_payload(data: dict) -> dict:
283
+ """Return gateway payload with plaintext `context` restored for tool code.
284
+
285
+ Legacy plaintext gateway rows are still accepted so existing users do not
286
+ lose access during rollout. New browser builds publish an encrypted JSON
287
+ envelope inside the relay's legacy `context` string field, because the
288
+ deployed relay validates that `context` is a string.
289
+ """
290
+ if not isinstance(data, dict):
291
+ return {"error": "getbased gateway returned invalid payload"}
292
+ envelope = data.get("encryptedContext")
293
+ context_value = data.get("context")
294
+ if not envelope and isinstance(context_value, str):
295
+ try:
296
+ parsed_context = json.loads(context_value)
297
+ if isinstance(parsed_context, dict):
298
+ envelope = parsed_context.get("encryptedContext")
299
+ except json.JSONDecodeError:
300
+ envelope = None
301
+ if envelope:
302
+ try:
303
+ profile_id = str(data.get("profileId") or "default")
304
+ decoded = dict(data)
305
+ decoded["context"] = _decrypt_agent_context(envelope, profile_id)
306
+ decoded.pop("encryptedContext", None)
307
+ return decoded
308
+ except ValueError as e:
309
+ return {"error": str(e)}
310
+ return data
311
+
312
+
313
+ async def _fetch_context(profile: str = "", *, decrypt_context: bool = True) -> dict:
314
+ """Fetch formatted lab context from the getbased sync gateway.
315
+
316
+ Profile discovery only needs token-authenticated metadata. Keep it usable
317
+ even on token-only installs by letting callers opt out of context decrypt.
318
+ """
183
319
  if not TOKEN:
184
320
  return {"error": "GETBASED_TOKEN not set"}
185
321
  try:
@@ -191,7 +327,8 @@ async def _fetch_context(profile: str = "") -> dict:
191
327
  params=params,
192
328
  )
193
329
  r.raise_for_status()
194
- return r.json()
330
+ data = r.json()
331
+ return _decode_context_payload(data) if decrypt_context else data
195
332
  except httpx.HTTPStatusError as e:
196
333
  return {"error": f"getbased gateway returned {e.response.status_code}"}
197
334
  except httpx.RequestError as e:
@@ -526,7 +663,7 @@ async def getbased_wearables_series(
526
663
  @_instrumented("getbased_list_profiles")
527
664
  async def getbased_list_profiles() -> str:
528
665
  """List all available profiles in getbased."""
529
- data = await _fetch_context()
666
+ data = await _fetch_context(decrypt_context=False)
530
667
  if "error" in data:
531
668
  return f"Error: {data['error']}"
532
669
  profiles = data.get("profiles") or []
@@ -4,14 +4,15 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "getbased-mcp"
7
- version = "0.2.4"
7
+ version = "0.2.6"
8
8
  description = "MCP server for querying blood work data and knowledge base from getbased"
9
9
  readme = "README.md"
10
- license = "GPL-3.0-only"
10
+ license = "AGPL-3.0-or-later"
11
11
  requires-python = ">=3.10"
12
12
  dependencies = [
13
13
  "mcp>=1.0.0",
14
14
  "httpx>=0.27",
15
+ "cryptography>=42.0",
15
16
  ]
16
17
 
17
18
  [project.optional-dependencies]
@@ -8,8 +8,13 @@ Uses respx to intercept httpx calls. Verifies:
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
+ import json
12
+
11
13
  import pytest
12
14
  import respx
15
+ from cryptography.hazmat.primitives import hashes
16
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
17
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
13
18
  from httpx import Response
14
19
 
15
20
 
@@ -21,6 +26,47 @@ GATEWAY_CONTEXT_URL = "https://gateway.test/api/context"
21
26
  LENS_URL_PREFIX = "http://lens.test:8322"
22
27
 
23
28
 
29
+ def _encrypted_context_payload(gm, context: str, profile_id: str = "abc", *, version: int = 2) -> dict:
30
+ import base64
31
+
32
+ iv = bytes(range(16, 28))
33
+ if version == 2:
34
+ raw_key = gm._decode_agent_context_key(gm.AGENT_CONTEXT_KEY)
35
+ aad = gm.AGENT_CONTEXT_AAD_PREFIX + b":" + profile_id.encode("utf-8")
36
+ ciphertext = AESGCM(raw_key).encrypt(iv, context.encode("utf-8"), aad)
37
+ envelope = {
38
+ "version": 2,
39
+ "alg": "AES-256-GCM",
40
+ "keyDerivation": "raw-256-bit-key",
41
+ "keyId": gm._agent_context_key_id(raw_key),
42
+ "iv": base64.b64encode(iv).decode("ascii"),
43
+ "ciphertext": base64.b64encode(ciphertext).decode("ascii"),
44
+ }
45
+ else:
46
+ salt = bytes(range(16))
47
+ key = HKDF(
48
+ algorithm=hashes.SHA256(),
49
+ length=32,
50
+ salt=salt,
51
+ info=gm.AGENT_CONTEXT_V1_KDF_INFO,
52
+ ).derive(gm._token_bytes(gm.TOKEN))
53
+ aad = gm.AGENT_CONTEXT_V1_KDF_INFO + b":" + profile_id.encode("utf-8")
54
+ ciphertext = AESGCM(key).encrypt(iv, context.encode("utf-8"), aad)
55
+ envelope = {
56
+ "version": 1,
57
+ "alg": "AES-256-GCM",
58
+ "kdf": "HKDF-SHA-256",
59
+ "info": gm.AGENT_CONTEXT_V1_KDF_INFO.decode("utf-8"),
60
+ "salt": base64.b64encode(salt).decode("ascii"),
61
+ "iv": base64.b64encode(iv).decode("ascii"),
62
+ "ciphertext": base64.b64encode(ciphertext).decode("ascii"),
63
+ }
64
+ return {
65
+ "profileId": profile_id,
66
+ "context": json.dumps({"encryptedContext": envelope}),
67
+ }
68
+
69
+
24
70
  @pytest.mark.asyncio
25
71
  @respx.mock
26
72
  async def test_getbased_list_profiles_happy(gm) -> None:
@@ -47,6 +93,20 @@ async def test_getbased_list_profiles_no_token(gm, monkeypatch: pytest.MonkeyPat
47
93
  assert "GETBASED_TOKEN not set" in out
48
94
 
49
95
 
96
+ @pytest.mark.asyncio
97
+ @respx.mock
98
+ async def test_getbased_list_profiles_works_without_context_key_for_encrypted_payload(gm, monkeypatch: pytest.MonkeyPatch) -> None:
99
+ payload = _encrypted_context_payload(gm, "[section:hormones]\nsecret\n[/section:hormones]", profile_id="abc")
100
+ payload["profiles"] = [{"id": "abc", "name": "Main"}]
101
+ monkeypatch.setattr(gm, "AGENT_CONTEXT_KEY", "")
102
+ respx.get(GATEWAY_CONTEXT_URL).mock(return_value=Response(200, json=payload))
103
+
104
+ out = await gm.getbased_list_profiles()
105
+
106
+ assert "abc Main" in out
107
+ assert "GETBASED_AGENT_CONTEXT_KEY" not in out
108
+
109
+
50
110
  @pytest.mark.asyncio
51
111
  @respx.mock
52
112
  async def test_getbased_lab_context_happy(gm) -> None:
@@ -61,6 +121,89 @@ async def test_getbased_lab_context_happy(gm) -> None:
61
121
  assert "testosterone" in out
62
122
 
63
123
 
124
+ @pytest.mark.asyncio
125
+ @respx.mock
126
+ async def test_getbased_lab_context_decrypts_agent_access_payload(gm) -> None:
127
+ plaintext = "[section:hormones]\ntestosterone: 18.4 nmol/L\n[/section:hormones]"
128
+ payload = _encrypted_context_payload(gm, plaintext, profile_id="abc")
129
+ assert "testosterone" not in payload["context"]
130
+ respx.get(GATEWAY_CONTEXT_URL).mock(return_value=Response(200, json=payload))
131
+
132
+ out = await gm.getbased_lab_context()
133
+
134
+ assert "Profile: abc" in out
135
+ assert "testosterone: 18.4" in out
136
+ assert "encryptedContext" not in out
137
+
138
+
139
+ @pytest.mark.asyncio
140
+ @respx.mock
141
+ async def test_getbased_lab_context_rejects_wrong_agent_context_key(gm, monkeypatch: pytest.MonkeyPatch) -> None:
142
+ payload = _encrypted_context_payload(gm, "[section:hormones]\nsecret\n[/section:hormones]", profile_id="abc")
143
+ monkeypatch.setattr(gm, "AGENT_CONTEXT_KEY", "gbctx_v1_ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8")
144
+ respx.get(GATEWAY_CONTEXT_URL).mock(return_value=Response(200, json=payload))
145
+
146
+ out = await gm.getbased_lab_context()
147
+
148
+ assert "Error:" in out
149
+ assert "could not be decrypted" in out
150
+
151
+
152
+ @pytest.mark.asyncio
153
+ @respx.mock
154
+ async def test_getbased_lab_context_token_alone_cannot_decrypt_v2(gm, monkeypatch: pytest.MonkeyPatch) -> None:
155
+ payload = _encrypted_context_payload(gm, "[section:hormones]\nsecret\n[/section:hormones]", profile_id="abc")
156
+ monkeypatch.setattr(gm, "AGENT_CONTEXT_KEY", "")
157
+ respx.get(GATEWAY_CONTEXT_URL).mock(return_value=Response(200, json=payload))
158
+
159
+ out = await gm.getbased_lab_context()
160
+
161
+ assert "Error:" in out
162
+ assert "GETBASED_AGENT_CONTEXT_KEY not set" in out
163
+
164
+
165
+ @pytest.mark.asyncio
166
+ @respx.mock
167
+ async def test_getbased_lab_context_rejects_non_utf8_plaintext(gm) -> None:
168
+ import base64
169
+
170
+ profile_id = "abc"
171
+ raw_key = gm._decode_agent_context_key(gm.AGENT_CONTEXT_KEY)
172
+ iv = bytes(range(16, 28))
173
+ aad = gm.AGENT_CONTEXT_AAD_PREFIX + b":" + profile_id.encode("utf-8")
174
+ ciphertext = AESGCM(raw_key).encrypt(iv, b"\xff\xfe\xfd", aad)
175
+ payload = {
176
+ "profileId": profile_id,
177
+ "context": json.dumps({"encryptedContext": {
178
+ "version": 2,
179
+ "alg": "AES-256-GCM",
180
+ "keyDerivation": "raw-256-bit-key",
181
+ "keyId": gm._agent_context_key_id(raw_key),
182
+ "iv": base64.b64encode(iv).decode("ascii"),
183
+ "ciphertext": base64.b64encode(ciphertext).decode("ascii"),
184
+ }}),
185
+ }
186
+ respx.get(GATEWAY_CONTEXT_URL).mock(return_value=Response(200, json=payload))
187
+
188
+ out = await gm.getbased_lab_context()
189
+
190
+ assert "Error:" in out
191
+ assert "invalid UTF-8" in out
192
+
193
+
194
+ @pytest.mark.asyncio
195
+ @respx.mock
196
+ async def test_getbased_lab_context_keeps_v1_rollout_fallback(gm, monkeypatch: pytest.MonkeyPatch) -> None:
197
+ plaintext = "[section:hormones]\nlegacy secret\n[/section:hormones]"
198
+ payload = _encrypted_context_payload(gm, plaintext, profile_id="abc", version=1)
199
+ monkeypatch.setattr(gm, "AGENT_CONTEXT_KEY", "")
200
+ respx.get(GATEWAY_CONTEXT_URL).mock(return_value=Response(200, json=payload))
201
+
202
+ out = await gm.getbased_lab_context()
203
+
204
+ assert "legacy secret" in out
205
+
206
+
64
207
  @pytest.mark.asyncio
65
208
  @respx.mock
66
209
  async def test_getbased_lab_context_gateway_error(gm) -> None:
@@ -1,22 +0,0 @@
1
- GNU GENERAL PUBLIC LICENSE
2
- Version 3, 29 June 2007
3
-
4
- Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5
- Everyone is permitted to copy and distribute verbatim copies
6
- of this license document, but changing it is not allowed.
7
-
8
- Preamble
9
-
10
- The GNU General Public License is a free, copyleft license for
11
- software and other kinds of works.
12
-
13
- The licenses for most software and other practical works are designed
14
- to take away your freedom to share and change the works. By contrast,
15
- the GNU General Public License is intended to guarantee your freedom to
16
- share and change all versions of a program--to make sure it remains free
17
- software for all its users. We, the Free Software Foundation, use the
18
- GNU General Public License for most of our software; it applies also to
19
- any other work released this way by its authors. You can apply it to
20
- your programs, too.
21
-
22
- For the full license text, see <https://www.gnu.org/licenses/gpl-3.0.txt>
File without changes