sourcemed-mcp 1.0.0__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.
server.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SourceMed MCP server.
|
|
3
|
+
|
|
4
|
+
Exposes the SourceMed library (https://sourcemed.io/) — a source-verified
|
|
5
|
+
medical evidence library built for agents — as MCP tools over stdio:
|
|
6
|
+
|
|
7
|
+
ask_sourcemed(question) Ask the library a clinical question. Calls the
|
|
8
|
+
site's Ask endpoint (POST /api/ask {"q": ...}),
|
|
9
|
+
which returns only verified cards, with shelf
|
|
10
|
+
citations, or {"covered": false}.
|
|
11
|
+
list_shelves() All shelves (number, title, area, card count).
|
|
12
|
+
get_shelf(n) One shelf in full: question, balance line,
|
|
13
|
+
MeSH, and its cards (headline, result, source).
|
|
14
|
+
search_shelves(query) Keyword search over shelf titles, questions,
|
|
15
|
+
balance lines and card headlines.
|
|
16
|
+
|
|
17
|
+
Data sources (public, no credentials):
|
|
18
|
+
- Shelf index: the homepage's public shelf sections
|
|
19
|
+
(https://sourcemed.io/). llms.txt documents the library and carries
|
|
20
|
+
the definitions in full, but does not itself contain a shelf list.
|
|
21
|
+
- Ask: POST https://sourcemed.io/api/ask with JSON {"q": question}.
|
|
22
|
+
|
|
23
|
+
HTTP is done with curl: behind some egress proxies, Python's HTTP stack
|
|
24
|
+
receives truncated streams from the site's worker (IncompleteRead at a
|
|
25
|
+
fixed byte count), while curl receives the full body reliably.
|
|
26
|
+
|
|
27
|
+
Terms (from the site): content CC BY with attribution — cite as
|
|
28
|
+
"SourceMed Shelf #NN, sourcemed.io". Content signals: ai-input=yes,
|
|
29
|
+
ai-train=no — query the live site; do not train on its content.
|
|
30
|
+
"""
|
|
31
|
+
import html as html_mod
|
|
32
|
+
import json
|
|
33
|
+
import re
|
|
34
|
+
import subprocess
|
|
35
|
+
import time
|
|
36
|
+
|
|
37
|
+
from mcp.server.fastmcp import FastMCP
|
|
38
|
+
|
|
39
|
+
BASE = "https://sourcemed.io"
|
|
40
|
+
HOME_URL = BASE + "/"
|
|
41
|
+
ASK_URL = BASE + "/api/ask"
|
|
42
|
+
UA = "sourcemed-mcp/1.0"
|
|
43
|
+
CACHE_TTL_SECONDS = 3600
|
|
44
|
+
|
|
45
|
+
mcp = FastMCP("sourcemed")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --------------------------------------------------------------------- HTTP
|
|
49
|
+
def _curl(args: list[str], timeout: int = 120) -> str:
|
|
50
|
+
out = subprocess.run(
|
|
51
|
+
["curl", "-sS", "-f", "--retry", "3", "-A", UA, *args],
|
|
52
|
+
capture_output=True,
|
|
53
|
+
timeout=timeout,
|
|
54
|
+
)
|
|
55
|
+
if out.returncode != 0:
|
|
56
|
+
raise RuntimeError(out.stderr.decode("utf-8", "replace").strip())
|
|
57
|
+
return out.stdout.decode("utf-8")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def fetch_text(url: str) -> str:
|
|
61
|
+
return _curl([url])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def ask_endpoint(question: str) -> dict:
|
|
65
|
+
body = json.dumps({"q": question})
|
|
66
|
+
raw = _curl(
|
|
67
|
+
[
|
|
68
|
+
"-X",
|
|
69
|
+
"POST",
|
|
70
|
+
"-H",
|
|
71
|
+
"Content-Type: application/json",
|
|
72
|
+
"-d",
|
|
73
|
+
body,
|
|
74
|
+
ASK_URL,
|
|
75
|
+
],
|
|
76
|
+
timeout=60,
|
|
77
|
+
)
|
|
78
|
+
return json.loads(raw)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------ parsing
|
|
82
|
+
def _clean(fragment: str) -> str:
|
|
83
|
+
text = re.sub(r"<[^>]+>", " ", fragment)
|
|
84
|
+
text = html_mod.unescape(text)
|
|
85
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_cache: dict = {"loaded_at": 0.0, "shelves": []}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _parse_areas(home: str) -> dict[int, str]:
|
|
92
|
+
areas: dict[int, str] = {}
|
|
93
|
+
starts = list(re.finditer(r'<div class="browse-area">', home))
|
|
94
|
+
for i, m in enumerate(starts):
|
|
95
|
+
end = starts[i + 1].start() if i + 1 < len(starts) else len(home)
|
|
96
|
+
block = home[m.start() : end]
|
|
97
|
+
hm = re.search(r"<h3>(.*?)</h3>", block, re.DOTALL)
|
|
98
|
+
if not hm:
|
|
99
|
+
continue
|
|
100
|
+
area = re.sub(r"\s*\(\d+\)\s*$", "", _clean(hm.group(1)))
|
|
101
|
+
for n in re.findall(r'href="#shelf(\d+)"', block):
|
|
102
|
+
areas.setdefault(int(n), area)
|
|
103
|
+
return areas
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_shelves(home: str) -> list[dict]:
|
|
107
|
+
areas = _parse_areas(home)
|
|
108
|
+
starts = list(re.finditer(r'<section id="shelf(\d+)"[^>]*>', home))
|
|
109
|
+
shelves = []
|
|
110
|
+
for i, m in enumerate(starts):
|
|
111
|
+
n = int(m.group(1))
|
|
112
|
+
end = starts[i + 1].start() if i + 1 < len(starts) else len(home)
|
|
113
|
+
chunk = home[m.start() : end]
|
|
114
|
+
|
|
115
|
+
title = ""
|
|
116
|
+
hm = re.search(r"<h2>(.*?)</h2>", chunk, re.DOTALL)
|
|
117
|
+
if hm:
|
|
118
|
+
htxt = _clean(hm.group(1))
|
|
119
|
+
title = htxt.split("·", 1)[1].strip() if "·" in htxt else htxt
|
|
120
|
+
|
|
121
|
+
question = ""
|
|
122
|
+
mesh = ""
|
|
123
|
+
for q in re.findall(r'<p class="topic-q">(.*?)</p>', chunk, re.DOTALL):
|
|
124
|
+
qtxt = _clean(q)
|
|
125
|
+
if qtxt.startswith("MeSH:"):
|
|
126
|
+
mesh = qtxt[len("MeSH:") :].strip()
|
|
127
|
+
elif not question:
|
|
128
|
+
question = re.sub(r"^The question:\s*", "", qtxt)
|
|
129
|
+
|
|
130
|
+
balance = ""
|
|
131
|
+
bm = re.search(r'<p class="balance">(.*?)</p>', chunk, re.DOTALL)
|
|
132
|
+
if bm:
|
|
133
|
+
balance = re.sub(
|
|
134
|
+
r"^The balance of the evidence:\s*", "", _clean(bm.group(1))
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
cards = []
|
|
138
|
+
for card_html in re.split(r'<article class="card"', chunk)[1:]:
|
|
139
|
+
headline = ""
|
|
140
|
+
ch = re.search(r"<h3>(.*?)</h3>", card_html, re.DOTALL)
|
|
141
|
+
if ch:
|
|
142
|
+
headline = _clean(ch.group(1))
|
|
143
|
+
result = ""
|
|
144
|
+
cr = re.search(
|
|
145
|
+
r'<p class="card-result">(.*?)</p>', card_html, re.DOTALL
|
|
146
|
+
)
|
|
147
|
+
if cr:
|
|
148
|
+
result = _clean(cr.group(1))
|
|
149
|
+
source = ""
|
|
150
|
+
cs = re.search(
|
|
151
|
+
r'<dd class="source">(.*?)</dd>', card_html, re.DOTALL
|
|
152
|
+
)
|
|
153
|
+
if cs:
|
|
154
|
+
source = _clean(cs.group(1))
|
|
155
|
+
if headline:
|
|
156
|
+
cards.append(
|
|
157
|
+
{"headline": headline, "result": result, "source": source}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
shelves.append(
|
|
161
|
+
{
|
|
162
|
+
"n": n,
|
|
163
|
+
"title": title,
|
|
164
|
+
"question": question,
|
|
165
|
+
"balance": balance,
|
|
166
|
+
"area": areas.get(n),
|
|
167
|
+
"mesh": mesh,
|
|
168
|
+
"cards": cards,
|
|
169
|
+
"url": f"{BASE}/#shelf{n}",
|
|
170
|
+
"citation": f"SourceMed Shelf #{n}, \u201c{title}\u201d, sourcemed.io",
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
shelves.sort(key=lambda s: s["n"])
|
|
174
|
+
return shelves
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _shelves() -> list[dict]:
|
|
178
|
+
now = time.time()
|
|
179
|
+
if not _cache["shelves"] or now - _cache["loaded_at"] > CACHE_TTL_SECONDS:
|
|
180
|
+
_cache["shelves"] = _parse_shelves(fetch_text(HOME_URL))
|
|
181
|
+
_cache["loaded_at"] = now
|
|
182
|
+
return _cache["shelves"]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _summary(shelf: dict) -> dict:
|
|
186
|
+
return {
|
|
187
|
+
"n": shelf["n"],
|
|
188
|
+
"title": shelf["title"],
|
|
189
|
+
"question": shelf["question"],
|
|
190
|
+
"balance": shelf["balance"],
|
|
191
|
+
"area": shelf["area"],
|
|
192
|
+
"card_count": len(shelf["cards"]),
|
|
193
|
+
"url": shelf["url"],
|
|
194
|
+
"citation": shelf["citation"],
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# -------------------------------------------------------------------- tools
|
|
199
|
+
@mcp.tool()
|
|
200
|
+
def ask_sourcemed(question: str) -> dict:
|
|
201
|
+
"""Ask SourceMed a clinical-evidence question.
|
|
202
|
+
|
|
203
|
+
Calls the library's Ask endpoint, which retrieves only source-verified
|
|
204
|
+
cards. Returns {"covered": true, "summary": ..., "shelves": [...]} with
|
|
205
|
+
full shelf records (title, question, balance line, cards with results
|
|
206
|
+
and sources/PMIDs), or {"covered": false, "shelves": []} when no shelf
|
|
207
|
+
covers the topic. Carry the shelf citations when you use the numbers.
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
return ask_endpoint(question)
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
return {"covered": False, "error": f"Ask endpoint call failed: {exc}"}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@mcp.tool()
|
|
216
|
+
def list_shelves() -> dict:
|
|
217
|
+
"""List every SourceMed shelf: number, title, browse area, card count."""
|
|
218
|
+
shelves = _shelves()
|
|
219
|
+
return {
|
|
220
|
+
"count": len(shelves),
|
|
221
|
+
"shelves": [
|
|
222
|
+
{
|
|
223
|
+
"n": s["n"],
|
|
224
|
+
"title": s["title"],
|
|
225
|
+
"area": s["area"],
|
|
226
|
+
"card_count": len(s["cards"]),
|
|
227
|
+
"url": s["url"],
|
|
228
|
+
}
|
|
229
|
+
for s in shelves
|
|
230
|
+
],
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@mcp.tool()
|
|
235
|
+
def get_shelf(n: int) -> dict:
|
|
236
|
+
"""Get one SourceMed shelf in full by its permanent number.
|
|
237
|
+
|
|
238
|
+
Returns the shelf's question, balance-of-evidence line, MeSH terms,
|
|
239
|
+
area, citation string, and every card (headline, result, source).
|
|
240
|
+
"""
|
|
241
|
+
for s in _shelves():
|
|
242
|
+
if s["n"] == n:
|
|
243
|
+
return s
|
|
244
|
+
return {"error": f"No shelf #{n} in the library."}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@mcp.tool()
|
|
248
|
+
def search_shelves(query: str) -> dict:
|
|
249
|
+
"""Search shelves by keyword.
|
|
250
|
+
|
|
251
|
+
Matches against shelf titles, questions, balance lines, and card
|
|
252
|
+
headlines. Returns up to 20 shelf summaries (number, title, question,
|
|
253
|
+
balance, area, card count, citation).
|
|
254
|
+
"""
|
|
255
|
+
terms = [t.lower() for t in re.split(r"\s+", query.strip()) if t]
|
|
256
|
+
if not terms:
|
|
257
|
+
return {"count": 0, "shelves": []}
|
|
258
|
+
hits = []
|
|
259
|
+
for s in _shelves():
|
|
260
|
+
haystack = " ".join(
|
|
261
|
+
[s["title"], s["question"], s["balance"]]
|
|
262
|
+
+ [c["headline"] for c in s["cards"]]
|
|
263
|
+
).lower()
|
|
264
|
+
if all(t in haystack for t in terms):
|
|
265
|
+
hits.append(_summary(s))
|
|
266
|
+
return {"count": len(hits), "shelves": hits[:20]}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def main():
|
|
270
|
+
mcp.run()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if __name__ == "__main__":
|
|
274
|
+
main()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sourcemed-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Source-verified medical evidence library for agents: every card traces to a checked primary source (PMID/DOI).
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: mcp<2,>=1.2
|
|
9
|
+
|
|
10
|
+
# SourceMed MCP server
|
|
11
|
+
|
|
12
|
+
An MCP (Model Context Protocol) server for [SourceMed](https://sourcemed.io/)
|
|
13
|
+
— a source-verified medical evidence library built for agents. Every card
|
|
14
|
+
on every shelf traces to a published primary source (PMID/DOI) whose
|
|
15
|
+
numbers were checked against that source before display.
|
|
16
|
+
|
|
17
|
+
The server uses only the site's **public endpoints and files**. No
|
|
18
|
+
account, API key, or credentials are needed.
|
|
19
|
+
|
|
20
|
+
## Tools
|
|
21
|
+
|
|
22
|
+
| Tool | What it does |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| `ask_sourcemed(question)` | Ask a clinical-evidence question. Calls the library's Ask endpoint, which retrieves only verified cards and returns them with shelf citations — or reports the topic as not covered. |
|
|
25
|
+
| `list_shelves()` | All shelves: permanent number, title, browse area, card count. |
|
|
26
|
+
| `get_shelf(n)` | One shelf in full: question, balance-of-evidence line, MeSH terms, and every card (headline, result, source). |
|
|
27
|
+
| `search_shelves(query)` | Keyword search over shelf titles, questions, balance lines, and card headlines. |
|
|
28
|
+
|
|
29
|
+
## Install and run
|
|
30
|
+
|
|
31
|
+
Requires Python 3.10+ and `curl` on PATH (see "Implementation notes").
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
cd sourcemed/mcp-server
|
|
35
|
+
python3 -m venv .venv
|
|
36
|
+
.venv/bin/pip install -r requirements.txt # the official mcp SDK, v1 line
|
|
37
|
+
.venv/bin/python server.py # stdio server; speaks MCP on stdin/stdout
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Connect it to an MCP client
|
|
41
|
+
|
|
42
|
+
Generic stdio client configuration:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"mcpServers": {
|
|
47
|
+
"sourcemed": {
|
|
48
|
+
"command": "/path/to/sourcemed/mcp-server/.venv/bin/python",
|
|
49
|
+
"args": ["/path/to/sourcemed/mcp-server/server.py"]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
If your environment reaches the internet through a proxy, make sure the
|
|
56
|
+
client passes `http_proxy` / `https_proxy` through to the server process
|
|
57
|
+
(some MCP hosts spawn servers with a minimal environment allowlist that
|
|
58
|
+
drops them; the server's HTTP calls will fail without them in proxied
|
|
59
|
+
environments).
|
|
60
|
+
|
|
61
|
+
## The Ask endpoint (discovered interface)
|
|
62
|
+
|
|
63
|
+
Documented in the site's `llms.txt` and verified live on 2026-11-03:
|
|
64
|
+
|
|
65
|
+
- **Request:** `POST https://sourcemed.io/api/ask` with header
|
|
66
|
+
`Content-Type: application/json` and body `{"q": "your question"}`.
|
|
67
|
+
GET with a query parameter returns **404** — POST is the only format.
|
|
68
|
+
The question must be a real question: a missing/too-short `q` returns
|
|
69
|
+
HTTP 400 with `{"covered": false, "error": "question too short"}`.
|
|
70
|
+
- **Response (covered):** HTTP 200,
|
|
71
|
+
`{"covered": true, "summary": null, "shelves": [ ... ]}` where each
|
|
72
|
+
shelf is `{"n", "title", "question", "balance", "area", "cards"}` and
|
|
73
|
+
each card is `{"headline", "type", "people", "result", "watchOut",
|
|
74
|
+
"source"}` — the source string carries the citation, PMID, and
|
|
75
|
+
registry ID. `summary` was `null` in all observed responses (the
|
|
76
|
+
shelves/cards are the answer; the endpoint retrieves, it does not
|
|
77
|
+
generate prose).
|
|
78
|
+
- **Response (not covered):** HTTP 200,
|
|
79
|
+
`{"covered": false, "summary": null, "shelves": []}`.
|
|
80
|
+
|
|
81
|
+
The endpoint never invents an answer: it returns only verified cards,
|
|
82
|
+
with shelf citations. Carry the citation (`SourceMed Shelf #NN,
|
|
83
|
+
sourcemed.io`) when you use a card's numbers.
|
|
84
|
+
|
|
85
|
+
## Data sources
|
|
86
|
+
|
|
87
|
+
- **Ask** — the endpoint above.
|
|
88
|
+
- **Shelf index** (`list_shelves` / `get_shelf` / `search_shelves`) —
|
|
89
|
+
parsed from the homepage's public shelf sections
|
|
90
|
+
(`https://sourcemed.io/`), cached in memory for one hour. Note:
|
|
91
|
+
`llms.txt` documents the library, the deposit rules, and the Ask
|
|
92
|
+
endpoint, and carries the site's definitions in full — but it does
|
|
93
|
+
not itself contain a shelf list, so the shelf tools read the same
|
|
94
|
+
public shelf sections the site serves to every visitor.
|
|
95
|
+
|
|
96
|
+
## Implementation notes
|
|
97
|
+
|
|
98
|
+
HTTP is performed with `curl` rather than Python's HTTP stack: behind
|
|
99
|
+
some egress proxies, Python's stack receives truncated streams from the
|
|
100
|
+
site's worker (IncompleteRead at a fixed byte count), while curl
|
|
101
|
+
receives the full body reliably. The server needs no other system
|
|
102
|
+
dependencies.
|
|
103
|
+
|
|
104
|
+
## Testing
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
.venv/bin/python test_client.py
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Spawns the server over stdio, initializes a session, lists tools, and
|
|
111
|
+
calls each tool once. Latest run (2026-11-03): **all checks PASS** —
|
|
112
|
+
`ask_sourcemed` returned 3 shelves for a statin-after-heart-attack
|
|
113
|
+
question; `list_shelves` returned 245 shelves; `get_shelf(106)` returned
|
|
114
|
+
the aspirin primary-prevention shelf with its 3 cards;
|
|
115
|
+
`search_shelves("aspirin primary prevention")` found shelf #106.
|
|
116
|
+
Full output: `test-output.txt` / `test-output.json`.
|
|
117
|
+
|
|
118
|
+
## Registry files
|
|
119
|
+
|
|
120
|
+
- `server.json` — manifest in the official MCP registry schema
|
|
121
|
+
(`io.sourcemed/sourcemed`, v1.0.0). Prepared for submission; **not yet
|
|
122
|
+
published** to any registry, and the PyPI package name
|
|
123
|
+
(`sourcemed-mcp`) is the intended identifier, not a live package.
|
|
124
|
+
- `smithery.yaml` — Smithery stdio start configuration, for a future
|
|
125
|
+
Smithery listing.
|
|
126
|
+
|
|
127
|
+
## Terms
|
|
128
|
+
|
|
129
|
+
Content is offered for reuse with attribution (CC BY): name SourceMed
|
|
130
|
+
and the shelf number. The site's content signals are `search=yes,
|
|
131
|
+
ai-input=yes, ai-train=no` — query the live site rather than absorbing
|
|
132
|
+
its content into training, so answers carry the current, corrected
|
|
133
|
+
version and the citation. The verification standard, not the license,
|
|
134
|
+
is the quality guarantee.
|
|
135
|
+
|
|
136
|
+
<!-- mcp-name: io.sourcemed/sourcemed -->
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
server.py,sha256=QXHaOJCt97zpgfJs5l5Pkkv6zWh3qeoq-Q7qElWs26o,8899
|
|
2
|
+
sourcemed_mcp-1.0.0.dist-info/METADATA,sha256=u5-MGabMey5wLbCGz67TKsZRWZo4wVa0OTXzpHeTKMQ,5504
|
|
3
|
+
sourcemed_mcp-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
4
|
+
sourcemed_mcp-1.0.0.dist-info/entry_points.txt,sha256=F8ztPwxsRsuq8h9gE-6WCc1dLDeAAsvUu6eD9mFWxBo,46
|
|
5
|
+
sourcemed_mcp-1.0.0.dist-info/top_level.txt,sha256=StKOSmRhvWS5IPcvhsDRbtxUTEofJgYFGOu5AAJdSWo,7
|
|
6
|
+
sourcemed_mcp-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
server
|