getbased-mcp 0.2.2__tar.gz → 0.2.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: getbased-mcp
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: MCP server for querying blood work data and knowledge base from getbased
5
5
  License-Expression: GPL-3.0-only
6
6
  Requires-Python: >=3.10
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: getbased-mcp
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: MCP server for querying blood work data and knowledge base from getbased
5
5
  License-Expression: GPL-3.0-only
6
6
  Requires-Python: >=3.10
@@ -8,4 +8,5 @@ getbased_mcp.egg-info/dependency_links.txt
8
8
  getbased_mcp.egg-info/entry_points.txt
9
9
  getbased_mcp.egg-info/requires.txt
10
10
  getbased_mcp.egg-info/top_level.txt
11
+ tests/test_env_loader.py
11
12
  tests/test_tools.py
@@ -21,6 +21,42 @@ import time
21
21
  import httpx
22
22
  from mcp.server.fastmcp import FastMCP
23
23
 
24
+
25
+ def _maybe_load_user_env() -> None:
26
+ """Opt-in: load $XDG_CONFIG_HOME/getbased/env into os.environ.
27
+
28
+ Guarded by GETBASED_STACK_MANAGED=1 so existing deployments that wire env
29
+ explicitly (Hermes via ~/.hermes/config.yaml, hand-rolled setups) are
30
+ untouched. Uses setdefault — an explicit env var always wins over the file.
31
+ Silent on a missing file; malformed lines skipped without crashing.
32
+ Escape hatch: GETBASED_NO_ENV_FILE=1 disables even when managed.
33
+ """
34
+ if os.environ.get("GETBASED_STACK_MANAGED") != "1":
35
+ return
36
+ if os.environ.get("GETBASED_NO_ENV_FILE") == "1":
37
+ return
38
+ xdg = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
39
+ path = os.path.join(xdg, "getbased", "env")
40
+ try:
41
+ with open(path, "r", encoding="utf-8") as fh:
42
+ lines = fh.readlines()
43
+ except OSError:
44
+ return
45
+ for raw in lines:
46
+ line = raw.strip()
47
+ if not line or line.startswith("#") or "=" not in line:
48
+ continue
49
+ key, _, val = line.partition("=")
50
+ key = key.strip()
51
+ val = val.strip()
52
+ if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
53
+ val = val[1:-1]
54
+ if key:
55
+ os.environ.setdefault(key, val)
56
+
57
+
58
+ _maybe_load_user_env()
59
+
24
60
  log = logging.getLogger("getbased_mcp")
25
61
 
26
62
  mcp = FastMCP("getbased")
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "getbased-mcp"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "MCP server for querying blood work data and knowledge base from getbased"
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-only"
@@ -0,0 +1,162 @@
1
+ """Tests for _maybe_load_user_env in getbased_mcp.
2
+
3
+ This is the critical guardrail for existing deployments like Hermes: without
4
+ GETBASED_STACK_MANAGED=1, the loader MUST be a no-op — no filesystem access,
5
+ no env mutation. Hermes doesn't set the flag and doesn't use the shared env
6
+ file, so these tests protect its MCP behavior from any regression.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+
16
+ @pytest.fixture
17
+ def env_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
18
+ """Isolated XDG_CONFIG_HOME with a getbased/env file containing a
19
+ sentinel value. Tests decide whether the loader sees it by toggling
20
+ GETBASED_STACK_MANAGED / GETBASED_NO_ENV_FILE."""
21
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
22
+ d = tmp_path / "getbased"
23
+ d.mkdir()
24
+ path = d / "env"
25
+ path.write_text("GETBASED_TOKEN=from_file\nLENS_URL=http://from-file:9999\n")
26
+ return path
27
+
28
+
29
+ def _load(monkeypatch: pytest.MonkeyPatch):
30
+ """Freshly import and return the loader function. Reload ensures we
31
+ don't accidentally share state between tests."""
32
+ import importlib
33
+
34
+ import getbased_mcp
35
+
36
+ importlib.reload(getbased_mcp)
37
+ return getbased_mcp._maybe_load_user_env
38
+
39
+
40
+ def test_noop_without_managed_flag(env_file, monkeypatch):
41
+ """Hermes-safety contract: no flag → no read, no mutation."""
42
+ monkeypatch.delenv("GETBASED_STACK_MANAGED", raising=False)
43
+ monkeypatch.delenv("GETBASED_TOKEN", raising=False)
44
+ monkeypatch.delenv("LENS_URL", raising=False)
45
+
46
+ loader = _load(monkeypatch)
47
+ loader()
48
+
49
+ assert "GETBASED_TOKEN" not in os.environ
50
+ assert "LENS_URL" not in os.environ
51
+
52
+
53
+ def test_loads_when_managed(env_file, monkeypatch):
54
+ monkeypatch.setenv("GETBASED_STACK_MANAGED", "1")
55
+ monkeypatch.delenv("GETBASED_TOKEN", raising=False)
56
+ monkeypatch.delenv("LENS_URL", raising=False)
57
+
58
+ loader = _load(monkeypatch)
59
+ loader()
60
+
61
+ assert os.environ["GETBASED_TOKEN"] == "from_file"
62
+ assert os.environ["LENS_URL"] == "http://from-file:9999"
63
+
64
+
65
+ def test_explicit_env_wins_over_file(env_file, monkeypatch):
66
+ """setdefault semantics — user/systemd-provided env must not be
67
+ overridden by the shared file."""
68
+ monkeypatch.setenv("GETBASED_STACK_MANAGED", "1")
69
+ monkeypatch.setenv("GETBASED_TOKEN", "from_shell")
70
+
71
+ loader = _load(monkeypatch)
72
+ loader()
73
+
74
+ assert os.environ["GETBASED_TOKEN"] == "from_shell"
75
+
76
+
77
+ def test_escape_hatch(env_file, monkeypatch):
78
+ """GETBASED_NO_ENV_FILE=1 bails even when managed — debugging/override use."""
79
+ monkeypatch.setenv("GETBASED_STACK_MANAGED", "1")
80
+ monkeypatch.setenv("GETBASED_NO_ENV_FILE", "1")
81
+ monkeypatch.delenv("GETBASED_TOKEN", raising=False)
82
+
83
+ loader = _load(monkeypatch)
84
+ loader()
85
+
86
+ assert "GETBASED_TOKEN" not in os.environ
87
+
88
+
89
+ def test_missing_file_silent(tmp_path, monkeypatch):
90
+ """No env file at the expected path → no error, no mutation."""
91
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
92
+ monkeypatch.setenv("GETBASED_STACK_MANAGED", "1")
93
+ monkeypatch.delenv("GETBASED_TOKEN", raising=False)
94
+
95
+ loader = _load(monkeypatch)
96
+ loader() # must not raise
97
+
98
+ assert "GETBASED_TOKEN" not in os.environ
99
+
100
+
101
+ def test_malformed_lines_skipped(tmp_path, monkeypatch):
102
+ """A typo'd env file must not crash MCP startup."""
103
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
104
+ d = tmp_path / "getbased"
105
+ d.mkdir()
106
+ (d / "env").write_text(
107
+ "# a comment\n"
108
+ "\n"
109
+ "NO_EQUALS_SIGN\n"
110
+ "GOOD_VAR=ok\n"
111
+ " =missing_key\n"
112
+ )
113
+ monkeypatch.setenv("GETBASED_STACK_MANAGED", "1")
114
+ monkeypatch.delenv("GOOD_VAR", raising=False)
115
+
116
+ loader = _load(monkeypatch)
117
+ loader()
118
+
119
+ assert os.environ["GOOD_VAR"] == "ok"
120
+
121
+
122
+ def test_quoted_values_unwrapped(tmp_path, monkeypatch):
123
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
124
+ d = tmp_path / "getbased"
125
+ d.mkdir()
126
+ (d / "env").write_text('DOUBLE="dv"\nSINGLE=\'sv\'\nBARE=bv\n')
127
+ monkeypatch.setenv("GETBASED_STACK_MANAGED", "1")
128
+ for k in ("DOUBLE", "SINGLE", "BARE"):
129
+ monkeypatch.delenv(k, raising=False)
130
+
131
+ loader = _load(monkeypatch)
132
+ loader()
133
+
134
+ assert os.environ["DOUBLE"] == "dv"
135
+ assert os.environ["SINGLE"] == "sv"
136
+ assert os.environ["BARE"] == "bv"
137
+
138
+
139
+ def test_module_import_parity_without_flag(monkeypatch, tmp_path):
140
+ """Smoke: importing getbased_mcp without the managed flag must not
141
+ read or mutate anything related to the shared env file. This is the
142
+ end-to-end Hermes contract — if this ever fails, we risk regressing
143
+ a live deployment."""
144
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
145
+ d = tmp_path / "getbased"
146
+ d.mkdir()
147
+ # Would poison LENS_URL if the loader ran
148
+ (d / "env").write_text("LENS_URL=http://POISON:1234\n")
149
+
150
+ monkeypatch.delenv("GETBASED_STACK_MANAGED", raising=False)
151
+ monkeypatch.setenv("LENS_URL", "http://explicit:8322")
152
+ monkeypatch.setenv("GETBASED_TOKEN", "real")
153
+
154
+ import importlib
155
+
156
+ import getbased_mcp
157
+
158
+ importlib.reload(getbased_mcp)
159
+
160
+ # Explicit env must survive; poison must not have been loaded
161
+ assert getbased_mcp.LENS_URL == "http://explicit:8322"
162
+ assert "POISON" not in getbased_mcp.LENS_URL
File without changes
File without changes
File without changes