getbased-mcp 0.2.2__py3-none-any.whl

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,248 @@
1
+ Metadata-Version: 2.4
2
+ Name: getbased-mcp
3
+ Version: 0.2.2
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
+ # getbased MCP Server
18
+
19
+ 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.).
20
+
21
+ > **Installing for the first time?** The [getbased-agent-stack](https://github.com/elkimek/getbased-agents/tree/main/packages/stack) meta-package bundles this MCP with the RAG engine it talks to, plus example configs for Claude Code and Hermes. One command and you're up.
22
+
23
+ ## How it works
24
+
25
+ ```
26
+ getbased (browser)
27
+ ├── your data, your mnemonic
28
+ ├── generates a read-only token
29
+ └── pushes lab context to sync gateway on every save
30
+
31
+ Sync Gateway (sync.getbased.health/api/context)
32
+ └── stores context text behind token auth
33
+
34
+ RAG Server (localhost, optional)
35
+ ├── Vector database with embedded chunks
36
+ ├── Embedding model for semantic search
37
+ └── Your curated health knowledge base
38
+
39
+ This MCP Server (on your machine)
40
+ ├── fetches blood work context from sync gateway
41
+ ├── queries RAG server for knowledge base searches (optional)
42
+ └── exposes everything as tools to any MCP client
43
+ ```
44
+
45
+ Your mnemonic never leaves your browser. The MCP server receives the same lab context text the getbased AI chat uses — not raw data.
46
+
47
+ ## Tools
48
+
49
+ | Tool | Description |
50
+ |---|---|
51
+ | `getbased_lab_context` | Full lab summary with biomarkers, context cards, supplements, goals. Pass `profile` to target a specific profile. |
52
+ | `getbased_section` | Get a specific section (e.g. hormones, lipids) or list all available sections |
53
+ | `getbased_list_profiles` | List available profiles |
54
+ | `knowledge_search` | Semantic search across the active library on your knowledge base (requires RAG server). Returns relevant passages with source attribution. |
55
+ | `knowledge_list_libraries` | List all knowledge base libraries and show which is active |
56
+ | `knowledge_activate_library` | Switch the active library — subsequent searches target the new one until switched again |
57
+ | `knowledge_stats` | Per-source chunk counts for the active library — useful for diagnosing missing results |
58
+ | `getbased_lens_config` | Show RAG endpoint config for getbased's Knowledge Base (External server) |
59
+
60
+ ### getbased_section
61
+
62
+ Query-aware context: pull just the section you need instead of the full dump. Saves tokens and allows deeper analysis of specific areas.
63
+
64
+ ```
65
+ # No args — returns section index with names, updated dates, and line counts
66
+ getbased_section()
67
+
68
+ # With section name — returns just that section's content
69
+ getbased_section(section="hormones")
70
+
71
+ # With profile — query a specific profile
72
+ getbased_section(section="hormones", profile="mne8m9hf")
73
+ ```
74
+
75
+ Section names are matched by prefix, so `hormones` matches `hormones updated:2026-03-13`.
76
+
77
+ ### knowledge_search
78
+
79
+ **What is RAG?** Retrieval-Augmented Generation (RAG) is a technique where an AI assistant's responses are grounded in a specific knowledge base. Instead of relying solely on training data, the assistant first searches a curated collection of documents for relevant passages, then uses those passages to inform its answer. This makes the AI's output more accurate, more specific, and traceable to real sources.
80
+
81
+ The `knowledge_search` tool searches your knowledge base using semantic similarity — meaning it finds passages that match the *meaning* of your query, not just keywords. Results include the passage text and source attribution.
82
+
83
+ ```
84
+ # Basic search
85
+ knowledge_search(query="blue light DHA mitochondrial damage")
86
+
87
+ # With result count (1–10, default 5)
88
+ knowledge_search(query="MTHFR methylation folate", n_results=5)
89
+ ```
90
+
91
+ **Note:** This tool requires the RAG server to be running. Without it, all blood work tools still work — the MCP degrades gracefully.
92
+
93
+ ### Multi-library (v0.2+)
94
+
95
+ The Lens server (0.2+ of `getbased-rag`) supports multiple libraries — keep research papers, clinical guides, and personal notes in separate collections and switch between them. `knowledge_search` always targets the currently active library.
96
+
97
+ ```
98
+ # See what's available and which is active
99
+ knowledge_list_libraries()
100
+
101
+ # Switch. Subsequent knowledge_search calls hit this library until switched again
102
+ knowledge_activate_library(library_id="<id-from-list>")
103
+
104
+ # Confirm what's indexed in the active library
105
+ knowledge_stats()
106
+ ```
107
+
108
+ ## Multi-profile
109
+
110
+ The gateway stores context per profile ID. To work with multiple profiles:
111
+
112
+ - Use `getbased_list_profiles` to see available profiles and their IDs
113
+ - Pass `profile="id"` to any tool to query a specific profile
114
+ - Omit the `profile` param to use the default profile
115
+ - Each profile's context is pushed automatically when data is saved or the profile is switched in getbased
116
+
117
+ ## Setup
118
+
119
+ ### 1. Enable messenger access in getbased
120
+
121
+ Go to **Settings > Data > Messenger Access** and toggle it on. Copy the read-only token.
122
+
123
+ ### 2. Set up a RAG server (optional — for knowledge_search)
124
+
125
+ The knowledge base runs as a separate service. You need:
126
+
127
+ - A vector database (e.g. [Qdrant](https://qdrant.tech/), [ChromaDB](https://www.trychroma.com/)) loaded with your document chunks and embeddings
128
+ - A FastAPI (or similar) server that accepts `POST /query` with `{version: 1, query: "...", top_k: N}` and returns `{chunks: [{text: "...", source: "..."}]}`
129
+ - An embedding model (e.g. [BGE-M3](https://huggingface.co/BAAI/bge-m3)) for semantic search
130
+
131
+ The RAG server handles embedding, similarity search, and filtering. This MCP just sends HTTP queries to it — no models loaded here.
132
+
133
+ **RAG server contract:**
134
+
135
+ | Field | Required | Description |
136
+ |---|---|---|
137
+ | `POST /query` | Yes | Accepts JSON body with `version` (int), `query` (string), `top_k` (int) |
138
+ | `Authorization` | Recommended | Bearer token auth |
139
+ | `GET /health` | Optional | Returns `{"status": "ok", "rag_ready": bool, "chunks": int}` |
140
+ | Response | Yes | `{"chunks": [{"text": "...", "source": "..."}]}` |
141
+
142
+ ### 3. Configure your MCP client
143
+
144
+ #### Claude Code / Claude Desktop
145
+
146
+ Add to your MCP config (`~/.claude/claude_desktop_config.json` or similar):
147
+
148
+ ```json
149
+ {
150
+ "mcpServers": {
151
+ "getbased": {
152
+ "command": "python3",
153
+ "args": ["/path/to/getbased_mcp.py"],
154
+ "env": {
155
+ "GETBASED_TOKEN": "your-token-here"
156
+ }
157
+ }
158
+ }
159
+ }
160
+ ```
161
+
162
+ #### Hermes Agent
163
+
164
+ ```bash
165
+ hermes mcp add getbased \
166
+ --command python3 \
167
+ --args /path/to/getbased_mcp.py
168
+ ```
169
+
170
+ Then set `GETBASED_TOKEN` in `~/.hermes/.env` or in the MCP server's `env` config in `config.yaml`:
171
+
172
+ ```yaml
173
+ mcp_servers:
174
+ getbased:
175
+ command: python3
176
+ args: [/path/to/getbased_mcp.py]
177
+ env:
178
+ GETBASED_TOKEN: your-token-here
179
+ ```
180
+
181
+ ### 4. Use it
182
+
183
+ Ask about your labs in any connected conversation:
184
+
185
+ > "How's my vitamin D?"
186
+ > "What markers are out of range?"
187
+ > "Summarize my latest blood work"
188
+ > "What does the knowledge base say about blue light and DHA?"
189
+
190
+ ## Environment variables
191
+
192
+ | Variable | Required | Description |
193
+ |---|---|---|
194
+ | `GETBASED_TOKEN` | Yes | Read-only token from getbased Settings > Data > Messenger Access |
195
+ | `GETBASED_GATEWAY` | No | Context gateway URL (default: `https://sync.getbased.health`) |
196
+ | `LENS_URL` | No | RAG server URL (default: `http://localhost:8322`). Overrides `LENS_PORT` |
197
+ | `LENS_PORT` | No | RAG server port, only used to build default `LENS_URL` (default: `8322`) |
198
+ | `LENS_API_KEY_FILE` | No | Path to RAG API key file. Default: `$XDG_DATA_HOME/getbased/lens/api_key` (getbased-rag's canonical location). If that file doesn't exist but the legacy `~/.hermes/rag/lens_api_key` does, the legacy path is used instead — upgrades from standalone `getbased-mcp` ≤ 0.1.0 keep working without config changes. |
199
+ | `LENS_MCP_ACTIVITY_LOG` | No | JSONL path where tool-call activity is appended. Default: `$XDG_STATE_HOME/getbased/mcp/activity.jsonl`. Each record: `{ts, tool, duration_ms, ok, error?}` — arguments are never logged (queries may contain sensitive health info). Set to `off` / `false` / `0` to disable. The [getbased-dashboard](https://github.com/elkimek/getbased-agents/tree/main/packages/dashboard) Activity tab tails this file. |
200
+
201
+ ## Custom Knowledge Source (getbased app)
202
+
203
+ The same RAG server that powers `knowledge_search` for your AI client can also back the in-app AI chat. To connect them:
204
+
205
+ 1. Run `getbased_lens_config` — it returns the endpoint URL, API key, and recommended `top_k`
206
+ 2. In getbased, go to **Settings → AI → Custom Knowledge Source**
207
+ 3. Paste the endpoint URL, API key, and set `top_k` to 5
208
+ 4. Enable it — the chat-header Lens badge will light up green when active
209
+
210
+ Every chat question and focus card will now be enriched with RAG-retrieved passages from your knowledge base.
211
+
212
+ ## Troubleshooting
213
+
214
+ ### `knowledge_search` returns "Lens server not reachable"
215
+
216
+ The RAG server isn't running. Start it and verify with:
217
+
218
+ ```bash
219
+ curl http://localhost:8322/health
220
+ ```
221
+
222
+ ### `knowledge_search` returns "Lens API key not found"
223
+
224
+ getbased-rag generates its API key on first start and writes it to `$XDG_DATA_HOME/getbased/lens/api_key` (e.g. `~/.local/share/getbased/lens/api_key` on Linux). If you're upgrading from the standalone `getbased-mcp` ≤ 0.1.0 and your key is at `~/.hermes/rag/lens_api_key`, that legacy path is still auto-detected — no config change needed. If the file is missing entirely, restart the RAG server and it will create a new one.
225
+
226
+ ### `knowledge_list_libraries` / `knowledge_stats` return "this lens server doesn't expose library management"
227
+
228
+ The lens server you're pointed at is older than `getbased-rag` 0.1.0 and doesn't implement the `/libraries` or `/stats` endpoints. `knowledge_search` still works against older lens servers since `/query` is protocol-stable. To get library management, either upgrade the lens, or set `LENS_URL` to a library-capable endpoint.
229
+
230
+ ### Blood work tools work but knowledge_search doesn't
231
+
232
+ That's expected — they're independent. Blood work tools talk to the sync gateway; knowledge_search talks to the RAG server. The MCP degrades gracefully: if the RAG server is down, all blood work tools continue to work normally.
233
+
234
+ ## Security
235
+
236
+ - **Read-only**: the token grants access to lab context text only — no raw data, no write access
237
+ - **Self-hosted**: the MCP server runs on your own machine
238
+ - **Revocable**: regenerate the token in getbased to revoke access instantly
239
+ - **No mnemonic exposure**: the token is independent of your sync mnemonic
240
+ - **No models in-process**: RAG queries go through the external server — no embedding models loaded in the MCP process
241
+
242
+ ## Related projects
243
+
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.
245
+
246
+ ## License
247
+
248
+ GPL-3.0
@@ -0,0 +1,7 @@
1
+ getbased_mcp.py,sha256=f9U-ccIL3luCFaiSrvptXvbbw4mFHNCqeEqJ_TdcWhc,20383
2
+ getbased_mcp-0.2.2.dist-info/licenses/LICENSE,sha256=K-IjLWkez1gJQMrlqA5zgyw8vh19mDzk4hKM9Dslmts,1024
3
+ getbased_mcp-0.2.2.dist-info/METADATA,sha256=JvIeETshnXJ02Jk8KT2avWcXu3L3PkqjcWvQR0SEwGI,11548
4
+ getbased_mcp-0.2.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ getbased_mcp-0.2.2.dist-info/entry_points.txt,sha256=2zDMmXMwt19of7uTcNc7QcAN_UEXNm_8WjmTY4S4XnE,54
6
+ getbased_mcp-0.2.2.dist-info/top_level.txt,sha256=DWlf4BxLap2aHz8j89KiHN08NKowHuSssssqKxIgZ9Y,13
7
+ getbased_mcp-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ getbased-mcp = getbased_mcp:mcp.run
@@ -0,0 +1,22 @@
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>
@@ -0,0 +1 @@
1
+ getbased_mcp
getbased_mcp.py ADDED
@@ -0,0 +1,488 @@
1
+ #!/usr/bin/env python3
2
+ """getbased MCP server — exposes blood work data and knowledge base search as tools.
3
+
4
+ Architecture:
5
+ getbased (browser) → sync gateway → this MCP → your AI client
6
+
7
+ Lens RAG server (Qdrant + BGE-M3)
8
+
9
+ Blood work data is fetched from the getbased sync gateway.
10
+ Knowledge base queries go through the Lens RAG server (separate process).
11
+ No models are loaded in this process — everything is HTTP.
12
+ """
13
+
14
+ import functools
15
+ import json
16
+ import logging
17
+ import os
18
+ import re
19
+ import time
20
+
21
+ import httpx
22
+ from mcp.server.fastmcp import FastMCP
23
+
24
+ log = logging.getLogger("getbased_mcp")
25
+
26
+ mcp = FastMCP("getbased")
27
+
28
+ # ── Config ───────────────────────────────────────────────────────────
29
+ TOKEN = os.environ.get("GETBASED_TOKEN", "")
30
+ GATEWAY = os.environ.get("GETBASED_GATEWAY", "https://sync.getbased.health")
31
+
32
+ LENS_URL = os.environ.get("LENS_URL", f"http://localhost:{os.environ.get('LENS_PORT', '8322')}")
33
+
34
+
35
+ def _resolve_default_key_file() -> str:
36
+ """Default Lens API key path. Prefer the XDG location used by getbased-rag;
37
+ fall back to the legacy ~/.hermes/rag/lens_api_key so upgrades from the
38
+ standalone getbased-mcp ≤ 0.1.0 don't silently break on boxes that still
39
+ have the old key there (e.g. Hermes VMs)."""
40
+ xdg = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
41
+ new_default = os.path.join(xdg, "getbased", "lens", "api_key")
42
+ legacy = os.path.expanduser("~/.hermes/rag/lens_api_key")
43
+ if not os.path.exists(new_default) and os.path.exists(legacy):
44
+ return legacy
45
+ return new_default
46
+
47
+
48
+ LENS_API_KEY_FILE = os.environ.get("LENS_API_KEY_FILE", _resolve_default_key_file())
49
+
50
+ # Friendly message surfaced when a tool hits a route the lens server doesn't
51
+ # expose (old lens, pre-libraries). See _lens_call's 404 handling.
52
+ _UNSUPPORTED_LENS_HINT = (
53
+ "this lens server doesn't expose library management. "
54
+ "Upgrade to getbased-rag ≥ 0.2.0, or point LENS_URL at a library-capable lens."
55
+ )
56
+
57
+ # Cap on how much of an upstream error body we echo back to the AI client.
58
+ # Rag's exception_handler emits its own {error: ...} payload — safe in a
59
+ # self-hosted trust model, but that error often ends up in a cloud LLM's
60
+ # context window where the full response text (stack traces, file paths)
61
+ # would be sensitive. Truncate to a short hint.
62
+ _UPSTREAM_ERROR_PREVIEW = 200
63
+
64
+
65
+ # ── Activity logging ────────────────────────────────────────────────
66
+ # Every tool call writes one JSONL record: tool name, wall-clock ts,
67
+ # duration in ms, success flag, and error-class on failure. **Args are
68
+ # never logged** — queries can contain sensitive health info, so we
69
+ # record the shape of usage, not its content. The dashboard tails this
70
+ # file for the Activity tab; with no dashboard installed the file is
71
+ # just a rotating-by-hand log the user can inspect.
72
+ #
73
+ # Default path is $XDG_STATE_HOME/getbased/mcp/activity.jsonl
74
+ # (~/.local/state/getbased/mcp/activity.jsonl on Linux). Override with
75
+ # LENS_MCP_ACTIVITY_LOG. Set LENS_MCP_ACTIVITY_LOG=off to disable.
76
+
77
+ def _activity_log_path() -> str:
78
+ """Resolve where activity records get appended. Env override wins;
79
+ otherwise XDG_STATE_HOME. Returns "" when logging is disabled."""
80
+ override = os.environ.get("LENS_MCP_ACTIVITY_LOG")
81
+ if override is not None:
82
+ return "" if override.lower() in ("off", "false", "0", "") else override
83
+ state = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state"))
84
+ return os.path.join(state, "getbased", "mcp", "activity.jsonl")
85
+
86
+
87
+ def _append_activity(tool: str, duration_ms: int, ok: bool, error: str) -> None:
88
+ """Best-effort JSONL append. Any I/O failure is swallowed — telemetry
89
+ must never break a tool call. Directory is created on first write."""
90
+ path = _activity_log_path()
91
+ if not path:
92
+ return
93
+ record = {
94
+ "ts": time.time(),
95
+ "tool": tool,
96
+ "duration_ms": duration_ms,
97
+ "ok": ok,
98
+ }
99
+ if error:
100
+ record["error"] = error
101
+ try:
102
+ os.makedirs(os.path.dirname(path), exist_ok=True)
103
+ with open(path, "a", encoding="utf-8") as f:
104
+ f.write(json.dumps(record) + "\n")
105
+ except OSError as e:
106
+ # Don't spam stderr in stdio MCP (it'd confuse the MCP client).
107
+ # Debug-level is fine; the user can inspect via Python logging.
108
+ log.debug("activity log append failed: %s", e)
109
+
110
+
111
+ def _instrumented(label: str):
112
+ """Wrap an async tool implementation with success/failure + duration
113
+ logging. Applied below each `@mcp.tool()` so the registered callable
114
+ is the instrumented one. Preserves the function's signature and
115
+ docstring via functools.wraps — FastMCP's tool registration reads
116
+ those to build the tool schema."""
117
+
118
+ def deco(fn):
119
+ @functools.wraps(fn)
120
+ async def wrapper(*args, **kwargs):
121
+ t0 = time.monotonic()
122
+ error_name = ""
123
+ ok = True
124
+ try:
125
+ return await fn(*args, **kwargs)
126
+ except Exception as e:
127
+ ok = False
128
+ error_name = type(e).__name__
129
+ raise
130
+ finally:
131
+ _append_activity(
132
+ label,
133
+ int((time.monotonic() - t0) * 1000),
134
+ ok,
135
+ error_name,
136
+ )
137
+
138
+ return wrapper
139
+
140
+ return deco
141
+
142
+
143
+ # ── Helpers ──────────────────────────────────────────────────────────
144
+
145
+ async def _fetch_context(profile: str = "") -> dict:
146
+ """Fetch formatted lab context from the getbased sync gateway."""
147
+ if not TOKEN:
148
+ return {"error": "GETBASED_TOKEN not set"}
149
+ try:
150
+ params = {"profile": profile} if profile else {}
151
+ async with httpx.AsyncClient(timeout=15) as client:
152
+ r = await client.get(
153
+ f"{GATEWAY}/api/context",
154
+ headers={"Authorization": f"Bearer {TOKEN}"},
155
+ params=params,
156
+ )
157
+ r.raise_for_status()
158
+ return r.json()
159
+ except httpx.HTTPStatusError as e:
160
+ return {"error": f"getbased gateway returned {e.response.status_code}"}
161
+ except httpx.RequestError as e:
162
+ return {"error": f"Failed to reach getbased gateway: {e}"}
163
+
164
+
165
+ def _parse_sections(context: str) -> dict[str, str]:
166
+ """Parse [section:name ...]...[/section:name] blocks → {full_name: content}."""
167
+ sections = {}
168
+ for m in re.finditer(
169
+ r"\[section:(\S+)([^\]]*)\]([\s\S]*?)\[/section:\1\]", context
170
+ ):
171
+ base = m.group(1)
172
+ meta = m.group(2).strip()
173
+ full_name = f"{base} {meta}" if meta else base
174
+ sections[full_name] = m.group(3).strip()
175
+ return sections
176
+
177
+
178
+ def _read_lens_key() -> str:
179
+ """Read the Lens API key from file (generated by lens_server.py)."""
180
+ try:
181
+ with open(LENS_API_KEY_FILE) as f:
182
+ key = f.read().strip()
183
+ return key if key else ""
184
+ except OSError:
185
+ return ""
186
+
187
+
188
+ async def _lens_request(query: str, top_k: int = 5) -> dict:
189
+ """Send a query to the Lens RAG server. Returns parsed JSON or error dict."""
190
+ key = _read_lens_key()
191
+ if not key:
192
+ return {"error": "Lens API key not found. Start lens_server.py first."}
193
+ try:
194
+ async with httpx.AsyncClient(timeout=60) as client:
195
+ r = await client.post(
196
+ f"{LENS_URL}/query",
197
+ headers={"Authorization": f"Bearer {key}"},
198
+ json={"version": 1, "query": query, "top_k": top_k},
199
+ )
200
+ r.raise_for_status()
201
+ if len(r.content) > 32 * 1024:
202
+ return {"error": "Lens response exceeds 32 KB — possible server issue"}
203
+ return r.json()
204
+ except httpx.ConnectError:
205
+ return {"error": f"Lens server not reachable at {LENS_URL}. Is it running?"}
206
+ except httpx.HTTPStatusError as e:
207
+ # The error surfaces into an AI client (typically cloud-hosted),
208
+ # so don't forward the raw response text — it may contain internal
209
+ # paths or stack traces. Truncate to a short preview; if an
210
+ # operator needs more detail they have the lens logs.
211
+ preview = (e.response.text or "")[:_UPSTREAM_ERROR_PREVIEW]
212
+ return {"error": f"Lens returned {e.response.status_code}: {preview}"}
213
+ except httpx.RequestError as e:
214
+ return {"error": f"Lens request failed: {e}"}
215
+ except (json.JSONDecodeError, ValueError) as e:
216
+ return {"error": f"Lens returned invalid JSON: {e}"}
217
+
218
+
219
+ async def _lens_call(method: str, path: str, json_body: dict | None = None) -> dict:
220
+ """Generic authenticated call to the Lens server. Same error contract as
221
+ _lens_request — every failure mode returns {"error": "..."} so tool
222
+ callsites can uniformly forward errors to the MCP client without trying
223
+ to catch exceptions themselves."""
224
+ key = _read_lens_key()
225
+ if not key:
226
+ return {"error": "Lens API key not found. Start lens_server.py first."}
227
+ try:
228
+ async with httpx.AsyncClient(timeout=30) as client:
229
+ r = await client.request(
230
+ method,
231
+ f"{LENS_URL}{path}",
232
+ headers={"Authorization": f"Bearer {key}"},
233
+ json=json_body,
234
+ )
235
+ r.raise_for_status()
236
+ return r.json() if r.content else {}
237
+ except httpx.ConnectError:
238
+ return {"error": f"Lens server not reachable at {LENS_URL}. Is it running?"}
239
+ except httpx.HTTPStatusError as e:
240
+ # Distinguish "this route doesn't exist on the server" (old lens, no
241
+ # /libraries or /stats endpoint) from a genuine 404 like "library id
242
+ # not found". FastAPI's default 404 body is `{"detail": "Not Found"}`;
243
+ # the new lens returns a structured error for real misses.
244
+ if e.response.status_code == 404:
245
+ try:
246
+ body = e.response.json()
247
+ if body.get("detail") == "Not Found":
248
+ return {"error": "unsupported_endpoint"}
249
+ except (json.JSONDecodeError, ValueError):
250
+ pass
251
+ # Same rationale as _lens_request: truncate the body preview so
252
+ # internal details don't end up in a cloud AI client's context.
253
+ preview = (e.response.text or "")[:_UPSTREAM_ERROR_PREVIEW]
254
+ return {"error": f"Lens returned {e.response.status_code}: {preview}"}
255
+ except httpx.RequestError as e:
256
+ return {"error": f"Lens request failed: {e}"}
257
+ except (json.JSONDecodeError, ValueError) as e:
258
+ return {"error": f"Lens returned invalid JSON: {e}"}
259
+
260
+
261
+ # ═══════════════════════════════════════════════════════════════════════
262
+ # TOOLS — Blood work data
263
+ # ═══════════════════════════════════════════════════════════════════════
264
+
265
+ @mcp.tool()
266
+ @_instrumented("getbased_lab_context")
267
+ async def getbased_lab_context(profile: str = "") -> str:
268
+ """Get a full summary of the user's blood work data, health context,
269
+ supplements, and goals from getbased. Use when the user asks broad
270
+ questions about their labs, biomarkers, or health trends.
271
+ Pass a profile ID to query a specific profile, or omit for the default."""
272
+ data = await _fetch_context(profile)
273
+ if "error" in data:
274
+ return f"Error: {data['error']}"
275
+ parts = []
276
+ if data.get("profileId"):
277
+ parts.append(f"Profile: {data['profileId']}")
278
+ if data.get("updatedAt"):
279
+ parts.append(f"Updated: {data['updatedAt']}")
280
+ parts.append(data.get("context", "No context available"))
281
+ return "\n\n".join(parts)
282
+
283
+
284
+ @mcp.tool()
285
+ @_instrumented("getbased_section")
286
+ async def getbased_section(section: str = "", profile: str = "") -> str:
287
+ """Get a specific section of health data, or list all available sections.
288
+ Call with no section name to get the index (section names + line counts).
289
+ Call with a section name to get just that section's content.
290
+ Sections include: biometrics, hormones, lipids, hematology, biochemistry,
291
+ supplements, goals, genetics, context cards, etc.
292
+ Section names are matched by prefix.
293
+ Pass a profile ID to query a specific profile, or omit for the default."""
294
+ data = await _fetch_context(profile)
295
+ if "error" in data:
296
+ return f"Error: {data['error']}"
297
+ context = data.get("context", "")
298
+ if not context:
299
+ return "No context available"
300
+
301
+ sections = _parse_sections(context)
302
+
303
+ if not section:
304
+ lines = []
305
+ for name, content in sections.items():
306
+ count = len([l for l in content.split("\n") if l.strip()])
307
+ lines.append(f" {name} ({count} lines)")
308
+ return "Available sections:\n\n" + "\n".join(lines)
309
+
310
+ query = section.lower().strip()
311
+ match_key = None
312
+ for k in sections:
313
+ if k.lower() == query:
314
+ match_key = k
315
+ break
316
+ if not match_key:
317
+ for k in sections:
318
+ if k.lower().startswith(query):
319
+ match_key = k
320
+ break
321
+ if not match_key:
322
+ available = [k.split(" ")[0] for k in sections]
323
+ return f'Section "{section}" not found\nAvailable: {", ".join(available)}'
324
+
325
+ return f"[{match_key}]\n\n{sections[match_key]}"
326
+
327
+
328
+ @mcp.tool()
329
+ @_instrumented("getbased_list_profiles")
330
+ async def getbased_list_profiles() -> str:
331
+ """List all available profiles in getbased."""
332
+ data = await _fetch_context()
333
+ if "error" in data:
334
+ return f"Error: {data['error']}"
335
+ profiles = data.get("profiles") or []
336
+ if not profiles:
337
+ return "No profiles found"
338
+ return "\n".join(
339
+ f"{p.get('id', '?')} {p.get('name', 'unnamed')}" for p in profiles
340
+ )
341
+
342
+
343
+ # ═══════════════════════════════════════════════════════════════════════
344
+ # TOOLS — Knowledge base (RAG via Lens server)
345
+ # ═══════════════════════════════════════════════════════════════════════
346
+
347
+ @mcp.tool()
348
+ @_instrumented("knowledge_search")
349
+ async def knowledge_search(
350
+ query: str,
351
+ n_results: int = 5,
352
+ ) -> str:
353
+ """Search the knowledge base for relevant passages using semantic similarity.
354
+
355
+ Searches the **currently active library** on the Lens server. If the user
356
+ has multiple libraries (research papers, clinical guides, personal notes),
357
+ list them with `knowledge_list_libraries` and switch with
358
+ `knowledge_activate_library` before searching.
359
+
360
+ Returns the top-K passages ranked by relevance, with source
361
+ attribution. Use this when the user asks about mechanisms, causal
362
+ relationships, or prescriptive guidance related to health topics.
363
+
364
+ Args:
365
+ query: Natural language search query (e.g. "folic acid MTHFR methylation")
366
+ n_results: Number of results to return (default 5, max 10)
367
+ """
368
+ n_results = max(1, min(10, n_results))
369
+ data = await _lens_request(query, top_k=n_results)
370
+
371
+ if "error" in data:
372
+ return f"Knowledge search error: {data['error']}"
373
+
374
+ chunks = data.get("chunks", [])[:10] # mirror web-app MAX_CHUNKS
375
+ if not chunks:
376
+ return "No results found for that query."
377
+
378
+ output_lines = []
379
+ for i, chunk in enumerate(chunks):
380
+ text = (chunk.get("text") or "")[:4000]
381
+ source = (chunk.get("source") or "")[:200]
382
+ output_lines.append(f"[{i + 1}] {source}")
383
+ output_lines.append(text)
384
+ output_lines.append("")
385
+
386
+ return "\n".join(output_lines)
387
+
388
+
389
+ @mcp.tool()
390
+ @_instrumented("getbased_lens_config")
391
+ async def getbased_lens_config() -> str:
392
+ """Get the Lens RAG endpoint configuration for getbased's Knowledge Base.
393
+ Returns the URL, API key, and recommended top_k to paste into
394
+ Settings → AI → Knowledge Base → External server in getbased.
395
+
396
+ Note: Treat the response as sensitive — it contains the API key in plaintext."""
397
+ key = _read_lens_key()
398
+ if not key:
399
+ return (
400
+ "Lens API key not found. Start lens_server.py first to generate one.\n"
401
+ f"Expected key file: {LENS_API_KEY_FILE}"
402
+ )
403
+ return (
404
+ f"Endpoint URL: {LENS_URL}/query\n"
405
+ f"API key (Bearer token): {key}\n"
406
+ f"Recommended top_k: 5\n\n"
407
+ "Paste these into getbased: Settings → AI → Knowledge Base → External server.\n"
408
+ "For production (non-localhost), use HTTPS via a reverse proxy."
409
+ )
410
+
411
+
412
+ @mcp.tool()
413
+ @_instrumented("knowledge_list_libraries")
414
+ async def knowledge_list_libraries() -> str:
415
+ """List all knowledge base libraries on the Lens server, showing which is
416
+ active. Use this to discover what collections the user has (research
417
+ papers, clinical guides, personal notes, etc.) before searching or
418
+ switching between them."""
419
+ data = await _lens_call("GET", "/libraries")
420
+ if data.get("error") == "unsupported_endpoint":
421
+ return f"Knowledge libraries: {_UNSUPPORTED_LENS_HINT}"
422
+ if "error" in data:
423
+ return f"Knowledge libraries error: {data['error']}"
424
+ libs = data.get("libraries") or []
425
+ active = data.get("activeId", "")
426
+ if not libs:
427
+ return "No libraries found. Ingest at least one document to create the default library."
428
+ lines = ["Libraries:"]
429
+ for lib in libs:
430
+ lib_id = lib.get("id", "")
431
+ name = lib.get("name", "unnamed")
432
+ marker = " (active)" if lib_id == active else ""
433
+ lines.append(f" {lib_id} {name}{marker}")
434
+ return "\n".join(lines)
435
+
436
+
437
+ @mcp.tool()
438
+ @_instrumented("knowledge_activate_library")
439
+ async def knowledge_activate_library(library_id: str) -> str:
440
+ """Switch the Lens server's active library. All subsequent
441
+ `knowledge_search` and `knowledge_stats` calls will target this library
442
+ until switched again. Use `knowledge_list_libraries` first to find the ID.
443
+
444
+ Args:
445
+ library_id: The library's ID (not its display name). Obtained from
446
+ knowledge_list_libraries.
447
+ """
448
+ if not library_id:
449
+ return "Error: library_id is required. Call knowledge_list_libraries to find one."
450
+ data = await _lens_call("POST", f"/libraries/{library_id}/activate")
451
+ if data.get("error") == "unsupported_endpoint":
452
+ return f"Activate library: {_UNSUPPORTED_LENS_HINT}"
453
+ if "error" in data:
454
+ return f"Activate library error: {data['error']}"
455
+ libs = data.get("libraries") or []
456
+ active = data.get("activeId", "")
457
+ for lib in libs:
458
+ if lib.get("id") == active:
459
+ return f"Active library is now: {lib.get('name', active)} ({active})"
460
+ return f"Active library is now: {active}"
461
+
462
+
463
+ @mcp.tool()
464
+ @_instrumented("knowledge_stats")
465
+ async def knowledge_stats() -> str:
466
+ """Get per-source chunk counts for the active knowledge base library.
467
+ Tells you which documents are indexed and how many excerpts each
468
+ contributes. Useful when diagnosing "I can't find X" — either the source
469
+ isn't indexed, or the relevant passages didn't score high enough."""
470
+ data = await _lens_call("GET", "/stats")
471
+ if data.get("error") == "unsupported_endpoint":
472
+ return f"Knowledge stats: {_UNSUPPORTED_LENS_HINT}"
473
+ if "error" in data:
474
+ return f"Knowledge stats error: {data['error']}"
475
+ total = data.get("total_chunks", 0)
476
+ docs = data.get("documents") or []
477
+ if not docs:
478
+ return f"Active library is empty (total chunks: {total})."
479
+ lines = [f"Total chunks: {total}", "", "Sources:"]
480
+ for doc in docs:
481
+ src = doc.get("source", "unknown")
482
+ chunks = doc.get("chunks", 0)
483
+ lines.append(f" {chunks:>6} {src}")
484
+ return "\n".join(lines)
485
+
486
+
487
+ if __name__ == "__main__":
488
+ mcp.run()