propaths-mcp 0.1.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.
- propaths_mcp/__init__.py +3 -0
- propaths_mcp/__main__.py +6 -0
- propaths_mcp/server.py +455 -0
- propaths_mcp-0.1.0.dist-info/METADATA +107 -0
- propaths_mcp-0.1.0.dist-info/RECORD +8 -0
- propaths_mcp-0.1.0.dist-info/WHEEL +4 -0
- propaths_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- propaths_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
propaths_mcp/__init__.py
ADDED
propaths_mcp/__main__.py
ADDED
propaths_mcp/server.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""ProPaths local/stdio MCP server.
|
|
2
|
+
|
|
3
|
+
Exposes the ProPaths verified interactome graph as MCP tools that mirror the
|
|
4
|
+
read-only HTTP API contract (the ``reads`` tag of the FastAPI OpenAPI spec)
|
|
5
|
+
1:1. Each tool is a thin pass-through to the API: the value it returns IS the
|
|
6
|
+
API's camelCase JSON body, so tool output cannot drift from the frozen API
|
|
7
|
+
contract.
|
|
8
|
+
|
|
9
|
+
Transport: stdio (the default). By default it targets the hosted API at
|
|
10
|
+
``https://propaths.net``; override with the ``PROPATHS_API_URL`` env var to
|
|
11
|
+
point at a local server. The API's read endpoints are Postgres-only, so nothing
|
|
12
|
+
here ever triggers the LLM pipeline.
|
|
13
|
+
|
|
14
|
+
Run against a local API:
|
|
15
|
+
PROPATHS_API_URL=http://localhost:8000 python -m propaths_mcp
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from typing import Any, Optional
|
|
22
|
+
from urllib.parse import quote
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
from mcp.server import MCPServer
|
|
26
|
+
from mcp.types import Completion, ToolAnnotations
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ann(title: str) -> ToolAnnotations:
|
|
30
|
+
"""Read-only, idempotent, closed-world annotations for every tool.
|
|
31
|
+
|
|
32
|
+
Signals to MCP clients that these tools are safe to call freely: they never
|
|
33
|
+
mutate state, repeat calls return the same result, and they operate over a
|
|
34
|
+
closed dataset (no open-ended external effects).
|
|
35
|
+
"""
|
|
36
|
+
return ToolAnnotations(
|
|
37
|
+
title=title,
|
|
38
|
+
read_only_hint=True,
|
|
39
|
+
idempotent_hint=True,
|
|
40
|
+
open_world_hint=False,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
DEFAULT_API_URL = "https://propaths.net"
|
|
44
|
+
_HTTP_TIMEOUT = 30.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _api_base() -> str:
|
|
48
|
+
"""Base URL of the ProPaths read API (no trailing slash)."""
|
|
49
|
+
return os.getenv("PROPATHS_API_URL", DEFAULT_API_URL).rstrip("/")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _make_client() -> httpx.AsyncClient:
|
|
53
|
+
"""Construct the HTTP client. Isolated so tests can inject a MockTransport."""
|
|
54
|
+
return httpx.AsyncClient(timeout=_HTTP_TIMEOUT)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _get(path: str, params: Optional[dict] = None) -> Any:
|
|
58
|
+
"""GET ``{API}{path}`` and return the parsed JSON body verbatim.
|
|
59
|
+
|
|
60
|
+
Pass-through by design: the returned object is exactly the API response, so
|
|
61
|
+
the MCP surface inherits the API's shapes with no second serialization to
|
|
62
|
+
drift. Errors are always RETURNED as a structured ``{"error", "status"}``
|
|
63
|
+
dict rather than raised, so the message reaches the calling agent: a 4xx/5xx
|
|
64
|
+
carries the API's detail (e.g. "Protein not found"); an unreachable API
|
|
65
|
+
returns ``status: null`` with a remediation hint (MCP would otherwise swallow
|
|
66
|
+
a raised exception into a generic "Error executing tool" message).
|
|
67
|
+
"""
|
|
68
|
+
url = f"{_api_base()}{path}"
|
|
69
|
+
try:
|
|
70
|
+
async with _make_client() as client:
|
|
71
|
+
resp = await client.get(url, params=params)
|
|
72
|
+
except httpx.RequestError as exc:
|
|
73
|
+
return {
|
|
74
|
+
"error": (
|
|
75
|
+
f"Could not reach the ProPaths API at {_api_base()}. Set "
|
|
76
|
+
"PROPATHS_API_URL to a running API (e.g. http://localhost:8000), "
|
|
77
|
+
f"or start one with `uvicorn api.app:app`. ({exc!r})"
|
|
78
|
+
),
|
|
79
|
+
"status": None,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if resp.status_code >= 400:
|
|
83
|
+
detail: Optional[str] = None
|
|
84
|
+
try:
|
|
85
|
+
detail = resp.json().get("detail")
|
|
86
|
+
except Exception: # non-JSON error body
|
|
87
|
+
detail = resp.text[:200] or None
|
|
88
|
+
return {"error": detail or f"HTTP {resp.status_code}", "status": resp.status_code}
|
|
89
|
+
|
|
90
|
+
return resp.json()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _get_text(path: str, params: Optional[dict] = None) -> str:
|
|
94
|
+
"""GET ``{API}{path}`` and return the raw text body (for exports).
|
|
95
|
+
|
|
96
|
+
On an unreachable API or a 4xx/5xx it returns a short ``error: ...`` string
|
|
97
|
+
rather than a body (never raises), so the message reaches the agent.
|
|
98
|
+
"""
|
|
99
|
+
url = f"{_api_base()}{path}"
|
|
100
|
+
try:
|
|
101
|
+
async with _make_client() as client:
|
|
102
|
+
resp = await client.get(url, params=params)
|
|
103
|
+
except httpx.RequestError as exc:
|
|
104
|
+
return (
|
|
105
|
+
f"error: could not reach the ProPaths API at {_api_base()}. Set "
|
|
106
|
+
f"PROPATHS_API_URL to a running API, or start one with "
|
|
107
|
+
f"`uvicorn api.app:app`. ({exc!r})"
|
|
108
|
+
)
|
|
109
|
+
if resp.status_code >= 400:
|
|
110
|
+
detail = None
|
|
111
|
+
try:
|
|
112
|
+
detail = resp.json().get("detail")
|
|
113
|
+
except Exception:
|
|
114
|
+
detail = None
|
|
115
|
+
return f"error: {detail or f'HTTP {resp.status_code}'}"
|
|
116
|
+
return resp.text
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _summarize_protein_page(page: dict) -> dict:
|
|
120
|
+
"""Project the full protein-page payload into an agent-sized overview.
|
|
121
|
+
|
|
122
|
+
The API's protein page is frontend-shaped: it bundles every interaction with
|
|
123
|
+
full mechanism/evidence prose (~700K tokens for a hub like ATXN3), which is
|
|
124
|
+
unusable as a single LLM tool result. This keeps one headline row per
|
|
125
|
+
interaction, resolves each edge's pathway ids to names from the page's own
|
|
126
|
+
pathway subtree, and drops the heavy prose. Depth is fetched on demand via
|
|
127
|
+
get_interaction.
|
|
128
|
+
"""
|
|
129
|
+
id_to_name = {pw["id"]: pw["name"] for pw in page.get("pathways", [])}
|
|
130
|
+
|
|
131
|
+
def edge(i: dict) -> dict:
|
|
132
|
+
names: list[str] = []
|
|
133
|
+
for fn in i.get("functions", []):
|
|
134
|
+
name = id_to_name.get(fn.get("canonicalPathwayId"))
|
|
135
|
+
if name and name not in names:
|
|
136
|
+
names.append(name)
|
|
137
|
+
return {
|
|
138
|
+
"id": i["id"],
|
|
139
|
+
"source": i["source"],
|
|
140
|
+
"target": i["target"],
|
|
141
|
+
"kind": i.get("kind"),
|
|
142
|
+
"direction": i.get("direction"),
|
|
143
|
+
"type": i.get("type"),
|
|
144
|
+
"functionCount": i.get("functionCount", 0),
|
|
145
|
+
"evidenceCount": i.get("evidenceCount", 0),
|
|
146
|
+
"supportSummary": i.get("supportSummary", ""),
|
|
147
|
+
"pathways": names,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interactions = page.get("interactions", [])
|
|
151
|
+
pathways = page.get("pathways", [])
|
|
152
|
+
return {
|
|
153
|
+
"main": page.get("main"),
|
|
154
|
+
"protein": page.get("protein"),
|
|
155
|
+
"counts": {
|
|
156
|
+
"interactions": len(interactions),
|
|
157
|
+
"pathways": len(pathways),
|
|
158
|
+
"proteins": len(page.get("proteins", [])),
|
|
159
|
+
},
|
|
160
|
+
"pathwayRoots": sorted(
|
|
161
|
+
pw["name"] for pw in pathways if pw.get("hierarchyLevel") == 0
|
|
162
|
+
),
|
|
163
|
+
"interactions": [edge(i) for i in interactions],
|
|
164
|
+
"note": (
|
|
165
|
+
"Overview only. For an edge's full mechanism/kinetics/evidence call "
|
|
166
|
+
"get_interaction(id)."
|
|
167
|
+
),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
server = MCPServer(
|
|
172
|
+
name="propaths",
|
|
173
|
+
title="ProPaths Interactome",
|
|
174
|
+
version="0.1.0",
|
|
175
|
+
instructions=(
|
|
176
|
+
"Read-only access to the ProPaths verified interactome graph: typed, "
|
|
177
|
+
"directed, mechanistic protein interactions, per-edge kinetics, and a "
|
|
178
|
+
"pathway ontology, all built from primary literature. New here? Call "
|
|
179
|
+
"describe_schema (or read the propaths://schema resource) and "
|
|
180
|
+
"list_interaction_types to orient. Typical flow: search_proteins to resolve "
|
|
181
|
+
"a symbol, get_protein for a compact overview, then drill into an edge with "
|
|
182
|
+
"get_interaction(id). list_interactions filters/sorts a protein's edges; "
|
|
183
|
+
"get_interaction_between fetches a specific pair; get_pathway is pathway-first; "
|
|
184
|
+
"get_highlights returns the strongest specimens; export_network dumps to "
|
|
185
|
+
"Cytoscape/networkx. All tools are read-only and idempotent."
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@server.tool(annotations=_ann("Search proteins"))
|
|
191
|
+
async def search_proteins(q: str, limit: int = 20) -> dict:
|
|
192
|
+
"""Search proteins by symbol prefix (case-insensitive).
|
|
193
|
+
|
|
194
|
+
Returns matching symbols with HGNC id and description. Use this first to
|
|
195
|
+
resolve a protein of interest, then call get_protein.
|
|
196
|
+
"""
|
|
197
|
+
return await _get("/api/search", {"q": q, "limit": limit})
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@server.tool(annotations=_ann("Get protein overview"))
|
|
201
|
+
async def get_protein(symbol: str) -> dict:
|
|
202
|
+
"""Overview map for one protein, the main entry point.
|
|
203
|
+
|
|
204
|
+
Returns a COMPACT overview sized for an agent: protein metadata, counts,
|
|
205
|
+
the top-level pathway roots, and one headline row per interaction (id,
|
|
206
|
+
oriented source/target, kind, direction, type, a one-line supportSummary,
|
|
207
|
+
resolved pathway names, and function/evidence counts). It deliberately omits
|
|
208
|
+
the heavy per-edge mechanism and evidence prose (the full protein page is
|
|
209
|
+
~700K tokens for a hub protein).
|
|
210
|
+
|
|
211
|
+
Drill into any row by id for full depth: get_interaction(id) for an edge's
|
|
212
|
+
mechanism + kinetics + evidence.
|
|
213
|
+
"""
|
|
214
|
+
page = await _get(f"/api/protein/{quote(symbol, safe='')}")
|
|
215
|
+
if isinstance(page, dict) and "error" in page:
|
|
216
|
+
return page
|
|
217
|
+
return _summarize_protein_page(page)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@server.tool(annotations=_ann("Get interaction detail"))
|
|
221
|
+
async def get_interaction(interaction_id: str, query: Optional[str] = None) -> dict:
|
|
222
|
+
"""One interaction's full enriched record by id.
|
|
223
|
+
|
|
224
|
+
Includes mechanism prose, direction, per-function effects, kinetics, and
|
|
225
|
+
evidence. Pass `query` (a participating symbol) to orient source/target so
|
|
226
|
+
the query protein reads as the source.
|
|
227
|
+
"""
|
|
228
|
+
params = {"query": query} if query else None
|
|
229
|
+
return await _get(f"/api/interaction/{quote(interaction_id, safe='')}", params)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@server.tool(annotations=_ann("Get pathway tree"))
|
|
233
|
+
async def get_pathway_tree() -> dict:
|
|
234
|
+
"""The full pathway scaffold as a flat node list.
|
|
235
|
+
|
|
236
|
+
Assemble the tree client-side via each node's parentId. Use this to resolve
|
|
237
|
+
the canonicalPathwayId values returned on interactions into human-readable
|
|
238
|
+
pathway names.
|
|
239
|
+
"""
|
|
240
|
+
return await _get("/api/pathways/tree")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@server.tool(annotations=_ann("Interaction between two proteins"))
|
|
244
|
+
async def get_interaction_between(a: str, b: str) -> dict:
|
|
245
|
+
"""The interaction(s) between two named proteins, oriented from `a`.
|
|
246
|
+
|
|
247
|
+
Use this for "what does A do to B" in one call, instead of pulling
|
|
248
|
+
get_protein and scanning for the partner.
|
|
249
|
+
"""
|
|
250
|
+
return await _get(f"/api/edge/{quote(a, safe='')}/{quote(b, safe='')}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@server.tool(annotations=_ann("List / filter interactions"))
|
|
254
|
+
async def list_interactions(
|
|
255
|
+
symbol: str,
|
|
256
|
+
kind: Optional[str] = None,
|
|
257
|
+
type: Optional[str] = None,
|
|
258
|
+
pathway: Optional[str] = None,
|
|
259
|
+
min_evidence: int = 0,
|
|
260
|
+
sort: str = "evidence",
|
|
261
|
+
order: str = "desc",
|
|
262
|
+
limit: int = 50,
|
|
263
|
+
) -> dict:
|
|
264
|
+
"""Filtered, sorted, headline-only list of a protein's interactions.
|
|
265
|
+
|
|
266
|
+
kind = activates|inhibits|binds|regulates; type = direct|indirect;
|
|
267
|
+
pathway = a pathway-name substring; min_evidence = minimum supporting
|
|
268
|
+
papers; sort = evidence|functions|partner. Lighter than get_protein; use it
|
|
269
|
+
for targeted questions ("best-evidenced inhibitory edges in ERAD"), then
|
|
270
|
+
drill in with get_interaction(id).
|
|
271
|
+
"""
|
|
272
|
+
params: dict = {"min_evidence": min_evidence, "sort": sort, "order": order, "limit": limit}
|
|
273
|
+
if kind:
|
|
274
|
+
params["kind"] = kind
|
|
275
|
+
if type:
|
|
276
|
+
params["type"] = type
|
|
277
|
+
if pathway:
|
|
278
|
+
params["pathway"] = pathway
|
|
279
|
+
return await _get(f"/api/protein/{quote(symbol, safe='')}/interactions", params)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@server.tool(annotations=_ann("Vocabulary + counts"))
|
|
283
|
+
async def list_interaction_types() -> dict:
|
|
284
|
+
"""The controlled vocabulary with plain-language meanings and live counts:
|
|
285
|
+
edge kinds, interaction types, directions, and the mechanisms present in the
|
|
286
|
+
graph. Call this before filtering so you use valid values.
|
|
287
|
+
"""
|
|
288
|
+
return await _get("/api/interaction-types")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@server.tool(annotations=_ann("Get pathway"))
|
|
292
|
+
async def get_pathway(pathway_id: str) -> dict:
|
|
293
|
+
"""A single pathway by id: the node, its ancestors and children, and the
|
|
294
|
+
interactions placed in it. The pathway-first way into the graph.
|
|
295
|
+
"""
|
|
296
|
+
return await _get(f"/api/pathway/{quote(pathway_id, safe='')}")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@server.tool(annotations=_ann("Curated highlights"))
|
|
300
|
+
async def get_highlights() -> dict:
|
|
301
|
+
"""A curated entry point: the best-evidenced interactions, ranked by
|
|
302
|
+
supporting evidence. Good for a quick, strong overview.
|
|
303
|
+
"""
|
|
304
|
+
return await _get("/api/highlights")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@server.tool(annotations=_ann("Export network"))
|
|
308
|
+
async def export_network(symbol: str, format: str = "tsv") -> str:
|
|
309
|
+
"""Export a protein's interactome as text for external tools.
|
|
310
|
+
|
|
311
|
+
format = tsv (edge list / spreadsheet), sif or graphml (Cytoscape,
|
|
312
|
+
networkx, igraph, Gephi). Edges carry their biological orientation (an
|
|
313
|
+
upstream partner points into the protein), so the graph is directed.
|
|
314
|
+
"""
|
|
315
|
+
return await _get_text(
|
|
316
|
+
f"/api/protein/{quote(symbol, safe='')}/network", {"format": format}
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# Static guidance (no HTTP): explains the vocabulary + how to drive the tools.
|
|
321
|
+
_SCHEMA_GUIDE = {
|
|
322
|
+
"graph": (
|
|
323
|
+
"A verified protein-protein interactome built from primary literature. "
|
|
324
|
+
"Interactions are typed, directed, and mechanistic. One protein (ATXN3) "
|
|
325
|
+
"is fully mapped today."
|
|
326
|
+
),
|
|
327
|
+
"edgeKinds": {
|
|
328
|
+
"activates": "source increases target activity/level",
|
|
329
|
+
"inhibits": "source decreases target activity/level",
|
|
330
|
+
"binds": "physical association, no signed effect",
|
|
331
|
+
"regulates": "modulates target, direction unspecified",
|
|
332
|
+
},
|
|
333
|
+
"interactionTypes": {
|
|
334
|
+
"direct": "physical / first-order interaction",
|
|
335
|
+
"indirect": "mediated through one or more intermediates",
|
|
336
|
+
},
|
|
337
|
+
"directions": {
|
|
338
|
+
"downstream": "query acts on the partner (query -> partner)",
|
|
339
|
+
"upstream": "partner acts on the query (partner -> query)",
|
|
340
|
+
"bidirectional": "mutual / no single causal direction",
|
|
341
|
+
},
|
|
342
|
+
"orientation": (
|
|
343
|
+
"Pass the query symbol to get_protein / get_interaction so source/target "
|
|
344
|
+
"read outward from it."
|
|
345
|
+
),
|
|
346
|
+
"flow": (
|
|
347
|
+
"search_proteins -> get_protein (overview) -> get_interaction for depth. "
|
|
348
|
+
"list_interactions filters; get_interaction_between fetches a specific "
|
|
349
|
+
"pair; get_pathway is pathway-first; list_interaction_types shows the "
|
|
350
|
+
"vocabulary; export_network dumps to Cytoscape/networkx."
|
|
351
|
+
),
|
|
352
|
+
"note": "canonicalPathwayId values resolve to names via get_pathway_tree.",
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@server.tool(annotations=_ann("Describe the graph"))
|
|
357
|
+
async def describe_schema() -> dict:
|
|
358
|
+
"""Explain the graph's vocabulary and how to use these tools: edge kinds,
|
|
359
|
+
interaction types, direction semantics, orientation, and the recommended
|
|
360
|
+
call flow. Static guidance that works even if the API is unreachable.
|
|
361
|
+
"""
|
|
362
|
+
return _SCHEMA_GUIDE
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
# Resources: readable context a client can attach without a tool call.
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
@server.resource("propaths://schema", name="schema",
|
|
370
|
+
description="Graph vocabulary + tool-usage guide", mime_type="application/json")
|
|
371
|
+
async def schema_resource() -> dict:
|
|
372
|
+
return _SCHEMA_GUIDE
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@server.resource("propaths://interaction-types", name="interaction-types",
|
|
376
|
+
description="Controlled vocabulary + counts", mime_type="application/json")
|
|
377
|
+
async def types_resource() -> dict:
|
|
378
|
+
return await _get("/api/interaction-types")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@server.resource("propaths://pathways/tree", name="pathways-tree",
|
|
382
|
+
description="Full pathway scaffold", mime_type="application/json")
|
|
383
|
+
async def tree_resource() -> dict:
|
|
384
|
+
return await _get("/api/pathways/tree")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@server.resource("propaths://protein/{symbol}", name="protein-overview",
|
|
388
|
+
description="Compact interactome overview for one protein",
|
|
389
|
+
mime_type="application/json")
|
|
390
|
+
async def protein_resource(symbol: str) -> dict:
|
|
391
|
+
page = await _get(f"/api/protein/{quote(symbol, safe='')}")
|
|
392
|
+
if isinstance(page, dict) and "error" in page:
|
|
393
|
+
return page
|
|
394
|
+
return _summarize_protein_page(page)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
# Prompts: reusable templates a client can surface to the user.
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
@server.prompt(name="profile-protein", title="Profile a protein",
|
|
402
|
+
description="Summarize a protein's interactome from the graph")
|
|
403
|
+
def profile_protein(symbol: str) -> str:
|
|
404
|
+
return (
|
|
405
|
+
f'Use the ProPaths tools to profile {symbol}. Start with get_protein("{symbol}") '
|
|
406
|
+
"for the overview, note the interaction-kind mix and top pathways, then call "
|
|
407
|
+
"get_interaction(id) on the two or three best-evidenced edges to explain their "
|
|
408
|
+
"mechanism and direction. Finish with a concise, sourced summary."
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@server.prompt(name="strongest-evidence", title="Strongest-evidence interactions",
|
|
413
|
+
description="Find a protein's best-supported interactions")
|
|
414
|
+
def strongest_evidence(symbol: str) -> str:
|
|
415
|
+
return (
|
|
416
|
+
f'Call list_interactions("{symbol}", sort="evidence", limit=5) for the best-'
|
|
417
|
+
"evidenced interactions, then get_interaction(id) on each to report the mechanism "
|
|
418
|
+
"and the supporting papers (PMIDs)."
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@server.prompt(name="explain-pathway", title="Explain a pathway",
|
|
423
|
+
description="Explain a pathway and what its members do")
|
|
424
|
+
def explain_pathway(pathway: str) -> str:
|
|
425
|
+
return (
|
|
426
|
+
f'Find the pathway matching "{pathway}" (via get_pathway_tree, or '
|
|
427
|
+
'list_interactions(pathway=...)), then use get_pathway(id) to list the interactions '
|
|
428
|
+
"placed in it and explain the biology in plain language."
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# ---------------------------------------------------------------------------
|
|
433
|
+
# Completion: autocomplete protein symbols for prompt args + the protein
|
|
434
|
+
# resource template (propaths://protein/{symbol}).
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
@server.completion()
|
|
438
|
+
async def complete(ref, argument, context) -> Optional[Completion]:
|
|
439
|
+
if argument.name in ("symbol", "protein") and (argument.value or "").strip():
|
|
440
|
+
try:
|
|
441
|
+
result = await _get("/api/search", {"q": argument.value, "limit": 15})
|
|
442
|
+
except Exception:
|
|
443
|
+
return None
|
|
444
|
+
if isinstance(result, dict) and result.get("results"):
|
|
445
|
+
return Completion(values=[r["symbol"] for r in result["results"]], has_more=False)
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def main() -> None:
|
|
450
|
+
"""Console entry point: run the MCP server over stdio."""
|
|
451
|
+
server.run(transport="stdio")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
if __name__ == "__main__":
|
|
455
|
+
main()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: propaths-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for the ProPaths verified protein-interactome API.
|
|
5
|
+
Project-URL: Homepage, https://propaths.net
|
|
6
|
+
Project-URL: Documentation, https://propaths.net/documentation
|
|
7
|
+
Project-URL: Repository, https://github.com/Tahsin-Kazi/propaths-mcp
|
|
8
|
+
Author: ProPaths
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: bioinformatics,interactome,mcp,propaths,protein
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: httpx>=0.27
|
|
17
|
+
Requires-Dist: mcp<3,>=2.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# propaths-mcp
|
|
21
|
+
|
|
22
|
+
An [MCP](https://modelcontextprotocol.io) server that exposes the **ProPaths**
|
|
23
|
+
verified protein-interactome as read-only tools for AI agents. It is a thin
|
|
24
|
+
client over the public ProPaths API (`https://propaths.net`), so every tool
|
|
25
|
+
returns exactly the API's JSON. No account, no API key.
|
|
26
|
+
|
|
27
|
+
ProPaths reads a protein's primary literature and returns a verified graph of
|
|
28
|
+
**typed, directed, mechanistic** interactions plus a pathway ontology. One
|
|
29
|
+
protein (ATXN3) is fully mapped today.
|
|
30
|
+
|
|
31
|
+
## Quickstart (Claude Desktop / any MCP client)
|
|
32
|
+
|
|
33
|
+
Add this to your MCP client config. `uvx` fetches and runs the server; nothing
|
|
34
|
+
to clone or install.
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"mcpServers": {
|
|
39
|
+
"propaths": {
|
|
40
|
+
"command": "uvx",
|
|
41
|
+
"args": ["propaths-mcp"]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Then ask, e.g., *"search ProPaths for SCA3 and summarize its strongest
|
|
48
|
+
mechanistic interaction."* The agent will call `search_proteins` then
|
|
49
|
+
`get_protein`, and drill in with `get_interaction`.
|
|
50
|
+
|
|
51
|
+
Prefer the raw API? It is public and keyless:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
curl https://propaths.net/api/protein/ATXN3
|
|
55
|
+
curl 'https://propaths.net/api/search?q=SCA3'
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Tools
|
|
59
|
+
|
|
60
|
+
| Tool | What it does |
|
|
61
|
+
|------|--------------|
|
|
62
|
+
| `search_proteins(q, limit=20)` | Find a protein by symbol, alias, or name (start here) |
|
|
63
|
+
| `get_protein(symbol)` | Compact interactome overview (the main entry point) |
|
|
64
|
+
| `get_interaction(interaction_id, query=None)` | One interaction's full mechanism + evidence |
|
|
65
|
+
| `get_interaction_between(a, b)` | The interaction(s) between two proteins, in one call |
|
|
66
|
+
| `list_interactions(symbol, kind=, type=, pathway=, min_evidence=, sort=, limit=)` | Filtered/sorted headline rows |
|
|
67
|
+
| `list_interaction_types()` | The controlled vocabulary (edge kinds, types, directions) + counts |
|
|
68
|
+
| `get_pathway(pathway_id)` | A pathway node with its lineage and member interactions |
|
|
69
|
+
| `get_pathway_tree()` | The full pathway scaffold (resolves pathway ids to names) |
|
|
70
|
+
| `get_highlights()` | The best-evidenced interactions |
|
|
71
|
+
| `export_network(symbol, format="tsv")` | Export a protein's network as TSV / SIF / GraphML (Cytoscape, networkx) |
|
|
72
|
+
| `describe_schema()` | The graph vocabulary + how to use the tools (offline) |
|
|
73
|
+
|
|
74
|
+
Also exposed as MCP **resources** (`propaths://schema`, `propaths://interaction-types`,
|
|
75
|
+
`propaths://pathways/tree`, and the `propaths://protein/{symbol}` template) and
|
|
76
|
+
**prompts** (`profile-protein`, `strongest-evidence`, `explain-pathway`).
|
|
77
|
+
|
|
78
|
+
All tools are read-only and idempotent.
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
| Env var | Default | Purpose |
|
|
83
|
+
|---------|---------|---------|
|
|
84
|
+
| `PROPATHS_API_URL` | `https://propaths.net` | API base URL. Point at `http://localhost:8000` to run against a local API. |
|
|
85
|
+
|
|
86
|
+
## Run without uvx
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install propaths-mcp
|
|
90
|
+
propaths-mcp # runs the stdio server
|
|
91
|
+
# or: python -m propaths_mcp
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Before it is published, you can run straight from the repo:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
uvx --from git+https://github.com/Tahsin-Kazi/propaths-mcp propaths-mcp
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Notes
|
|
101
|
+
|
|
102
|
+
- Read-only and public; reads are rate-limited per client. Write/enrichment
|
|
103
|
+
access and a hosted MCP are gated. Get in touch.
|
|
104
|
+
- Errors are graceful: a missing protein returns `{"error": "...", "status": 404}`;
|
|
105
|
+
an unreachable API raises with a hint.
|
|
106
|
+
|
|
107
|
+
Docs: <https://propaths.net/quick-start> · License: MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
propaths_mcp/__init__.py,sha256=hyhlFsHcHBM33UK6wgS2KMJfv4fXXaZT1v5s2xdU5fc,107
|
|
2
|
+
propaths_mcp/__main__.py,sha256=Jo-473-IWiq4hgbtMBteA6Y98bDxGsb4n9A1bMX0tzA,106
|
|
3
|
+
propaths_mcp/server.py,sha256=1gFa_rEqCwvE1gozBK67n2iyNALGvaoa0jhXibKWdYo,18178
|
|
4
|
+
propaths_mcp-0.1.0.dist-info/METADATA,sha256=zIfrONqFOe5nFyJel8n5HqAIwryG2N0nloQjKwXDlAo,3953
|
|
5
|
+
propaths_mcp-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
+
propaths_mcp-0.1.0.dist-info/entry_points.txt,sha256=x5AfGxslrGv74jTlxGVsjjf7Ssy-qzzMx-xIB489zKw,58
|
|
7
|
+
propaths_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=A4Hztgc8zk2mLlzkwSkIcO16cH_qLDkkwMvZFAvoGT4,1065
|
|
8
|
+
propaths_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ProPaths
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|