propaths-mcp 0.1.0__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,2 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
@@ -0,0 +1,9 @@
1
+ dist/
2
+ build/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .venv/
6
+ venv/
7
+ *.pyc
8
+ .pytest_cache/
9
+ .uv/
@@ -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.
@@ -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,88 @@
1
+ # propaths-mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server that exposes the **ProPaths**
4
+ verified protein-interactome as read-only tools for AI agents. It is a thin
5
+ client over the public ProPaths API (`https://propaths.net`), so every tool
6
+ returns exactly the API's JSON. No account, no API key.
7
+
8
+ ProPaths reads a protein's primary literature and returns a verified graph of
9
+ **typed, directed, mechanistic** interactions plus a pathway ontology. One
10
+ protein (ATXN3) is fully mapped today.
11
+
12
+ ## Quickstart (Claude Desktop / any MCP client)
13
+
14
+ Add this to your MCP client config. `uvx` fetches and runs the server; nothing
15
+ to clone or install.
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "propaths": {
21
+ "command": "uvx",
22
+ "args": ["propaths-mcp"]
23
+ }
24
+ }
25
+ }
26
+ ```
27
+
28
+ Then ask, e.g., *"search ProPaths for SCA3 and summarize its strongest
29
+ mechanistic interaction."* The agent will call `search_proteins` then
30
+ `get_protein`, and drill in with `get_interaction`.
31
+
32
+ Prefer the raw API? It is public and keyless:
33
+
34
+ ```bash
35
+ curl https://propaths.net/api/protein/ATXN3
36
+ curl 'https://propaths.net/api/search?q=SCA3'
37
+ ```
38
+
39
+ ## Tools
40
+
41
+ | Tool | What it does |
42
+ |------|--------------|
43
+ | `search_proteins(q, limit=20)` | Find a protein by symbol, alias, or name (start here) |
44
+ | `get_protein(symbol)` | Compact interactome overview (the main entry point) |
45
+ | `get_interaction(interaction_id, query=None)` | One interaction's full mechanism + evidence |
46
+ | `get_interaction_between(a, b)` | The interaction(s) between two proteins, in one call |
47
+ | `list_interactions(symbol, kind=, type=, pathway=, min_evidence=, sort=, limit=)` | Filtered/sorted headline rows |
48
+ | `list_interaction_types()` | The controlled vocabulary (edge kinds, types, directions) + counts |
49
+ | `get_pathway(pathway_id)` | A pathway node with its lineage and member interactions |
50
+ | `get_pathway_tree()` | The full pathway scaffold (resolves pathway ids to names) |
51
+ | `get_highlights()` | The best-evidenced interactions |
52
+ | `export_network(symbol, format="tsv")` | Export a protein's network as TSV / SIF / GraphML (Cytoscape, networkx) |
53
+ | `describe_schema()` | The graph vocabulary + how to use the tools (offline) |
54
+
55
+ Also exposed as MCP **resources** (`propaths://schema`, `propaths://interaction-types`,
56
+ `propaths://pathways/tree`, and the `propaths://protein/{symbol}` template) and
57
+ **prompts** (`profile-protein`, `strongest-evidence`, `explain-pathway`).
58
+
59
+ All tools are read-only and idempotent.
60
+
61
+ ## Configuration
62
+
63
+ | Env var | Default | Purpose |
64
+ |---------|---------|---------|
65
+ | `PROPATHS_API_URL` | `https://propaths.net` | API base URL. Point at `http://localhost:8000` to run against a local API. |
66
+
67
+ ## Run without uvx
68
+
69
+ ```bash
70
+ pip install propaths-mcp
71
+ propaths-mcp # runs the stdio server
72
+ # or: python -m propaths_mcp
73
+ ```
74
+
75
+ Before it is published, you can run straight from the repo:
76
+
77
+ ```bash
78
+ uvx --from git+https://github.com/Tahsin-Kazi/propaths-mcp propaths-mcp
79
+ ```
80
+
81
+ ## Notes
82
+
83
+ - Read-only and public; reads are rate-limited per client. Write/enrichment
84
+ access and a hosted MCP are gated. Get in touch.
85
+ - Errors are graceful: a missing protein returns `{"error": "...", "status": 404}`;
86
+ an unreachable API raises with a hint.
87
+
88
+ Docs: <https://propaths.net/quick-start> · License: MIT
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "propaths-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for the ProPaths verified protein-interactome API."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "ProPaths" }]
13
+ keywords = ["mcp", "bioinformatics", "protein", "interactome", "propaths"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = [
20
+ "mcp>=2.0,<3",
21
+ "httpx>=0.27",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://propaths.net"
26
+ Documentation = "https://propaths.net/documentation"
27
+ Repository = "https://github.com/Tahsin-Kazi/propaths-mcp"
28
+
29
+ [project.scripts]
30
+ propaths-mcp = "propaths_mcp.server:main"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/propaths_mcp"]
@@ -0,0 +1,3 @@
1
+ """ProPaths MCP server: read-only agent tools over the ProPaths interactome API."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m propaths_mcp``."""
2
+
3
+ from .server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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,318 @@
1
+ """Tests for the local/stdio MCP server (``mcp_server/server.py``).
2
+
3
+ Deterministic and offline: the HTTP layer is stubbed with ``httpx.MockTransport``
4
+ so no API, database, network, or LLM is touched. These lock the two things
5
+ that make the MCP a faithful mirror of the read API:
6
+
7
+ 1. every tool hits the correct /api/* path and forwards its params, and
8
+ 2. the API JSON is passed through verbatim (so the tool output cannot drift
9
+ from the frozen API contract), with HTTP errors surfaced as usable messages.
10
+
11
+ Run:
12
+ python -m pytest tests/ -q
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+
20
+ import httpx
21
+ import pytest
22
+
23
+ from propaths_mcp import server as srv
24
+
25
+
26
+ def _run(coro):
27
+ return asyncio.run(coro)
28
+
29
+
30
+ def _install_mock(monkeypatch, handler):
31
+ """Point the server's client factory at a MockTransport driven by `handler`."""
32
+ monkeypatch.setattr(
33
+ srv, "_make_client",
34
+ lambda: httpx.AsyncClient(transport=httpx.MockTransport(handler)),
35
+ )
36
+
37
+
38
+ def _recording_handler(recorded: list, payload, status: int = 200):
39
+ """A handler that records each request and returns a fixed JSON `payload`."""
40
+ def handler(request: httpx.Request) -> httpx.Response:
41
+ recorded.append(request)
42
+ return httpx.Response(status, json=payload)
43
+ return handler
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Registration
48
+ # ---------------------------------------------------------------------------
49
+
50
+ def test_all_tools_registered_and_annotated():
51
+ tools = _run(srv.server.list_tools())
52
+ names = {t.name for t in tools}
53
+ assert names == {
54
+ "search_proteins", "get_protein", "get_interaction",
55
+ "get_pathway_tree", "get_interaction_between",
56
+ "list_interactions", "list_interaction_types", "get_pathway",
57
+ "get_highlights", "export_network", "describe_schema",
58
+ }
59
+ # every tool advertises read-only + idempotent + closed-world
60
+ for t in tools:
61
+ assert t.annotations is not None
62
+ assert t.annotations.read_only_hint is True
63
+ assert t.annotations.idempotent_hint is True
64
+ assert t.annotations.open_world_hint is False
65
+
66
+
67
+ def test_resources_prompts_and_templates_registered():
68
+ resources = {str(r.uri) for r in _run(srv.server.list_resources())}
69
+ assert resources == {
70
+ "propaths://schema",
71
+ "propaths://interaction-types", "propaths://pathways/tree",
72
+ }
73
+ templates = {t.uri_template for t in _run(srv.server.list_resource_templates())}
74
+ assert templates == {"propaths://protein/{symbol}"}
75
+ prompts = {p.name for p in _run(srv.server.list_prompts())}
76
+ assert prompts == {"profile-protein", "strongest-evidence", "explain-pathway"}
77
+
78
+
79
+ def test_get_protein_tool_schema_exposes_symbol():
80
+ tools = {t.name: t for t in _run(srv.server.list_tools())}
81
+ props = tools["get_protein"].input_schema.get("properties", {})
82
+ assert "symbol" in props
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Path routing + pass-through
87
+ # ---------------------------------------------------------------------------
88
+
89
+ def test_get_protein_hits_path_and_returns_overview(monkeypatch):
90
+ rec: list = []
91
+ body = {"main": "ATXN3", "protein": {"symbol": "ATXN3"},
92
+ "proteins": ["ATXN3", "VCP"],
93
+ "interactions": [], "chains": [], "pathways": []}
94
+ _install_mock(monkeypatch, _recording_handler(rec, body))
95
+
96
+ result = _run(srv.get_protein("ATXN3"))
97
+
98
+ assert rec[0].url.path == "/api/protein/ATXN3"
99
+ # get_protein returns the compact overview, not the raw page
100
+ assert result["main"] == "ATXN3"
101
+ assert result["counts"] == {"interactions": 0, "pathways": 0, "proteins": 2}
102
+ assert "note" in result
103
+
104
+
105
+ def test_get_interaction_is_verbatim_passthrough(monkeypatch):
106
+ # detail tools stay pure pass-through (only get_protein is projected)
107
+ rec: list = []
108
+ body = {"id": "i1", "source": "ATXN3", "target": "VCP",
109
+ "functions": [{"function": "x"}], "mechanism": "deep prose"}
110
+ _install_mock(monkeypatch, _recording_handler(rec, body))
111
+ assert _run(srv.get_interaction("i1")) == body
112
+
113
+
114
+ def test_search_forwards_query_and_limit(monkeypatch):
115
+ rec: list = []
116
+ _install_mock(monkeypatch, _recording_handler(rec, {"query": "AT", "results": []}))
117
+
118
+ _run(srv.search_proteins("AT", limit=5))
119
+
120
+ assert rec[0].url.path == "/api/search"
121
+ assert dict(rec[0].url.params) == {"q": "AT", "limit": "5"}
122
+
123
+
124
+ def test_interaction_orient_query_is_optional(monkeypatch):
125
+ rec: list = []
126
+ _install_mock(monkeypatch, _recording_handler(rec, {"id": "i1"}))
127
+
128
+ _run(srv.get_interaction("i1", query="ATXN3"))
129
+ assert rec[0].url.path == "/api/interaction/i1"
130
+ assert dict(rec[0].url.params) == {"query": "ATXN3"}
131
+
132
+ _run(srv.get_interaction("i1"))
133
+ assert dict(rec[1].url.params) == {} # no query param when omitted
134
+
135
+
136
+ def test_pathway_tree_path(monkeypatch):
137
+ rec: list = []
138
+ _install_mock(monkeypatch, _recording_handler(rec, {"pathways": [], "count": 0}))
139
+ _run(srv.get_pathway_tree())
140
+ assert rec[0].url.path == "/api/pathways/tree"
141
+
142
+
143
+ def test_path_segment_is_url_encoded(monkeypatch):
144
+ rec: list = []
145
+ _install_mock(monkeypatch, _recording_handler(rec, {}))
146
+ _run(srv.get_protein("A/B"))
147
+ # a stray slash must stay percent-encoded on the wire so it cannot escape
148
+ # the path segment (`.path` decodes it back; the raw URL preserves it)
149
+ assert "/api/protein/A%2FB" in str(rec[0].url)
150
+
151
+
152
+ def test_respects_PROPATHS_API_URL_env(monkeypatch):
153
+ rec: list = []
154
+ monkeypatch.setenv("PROPATHS_API_URL", "http://example.test:9000/")
155
+ _install_mock(monkeypatch, _recording_handler(rec, {"pathways": []}))
156
+ _run(srv.get_pathway_tree())
157
+ assert str(rec[0].url) == "http://example.test:9000/api/pathways/tree"
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Error handling
162
+ # ---------------------------------------------------------------------------
163
+
164
+ def test_http_404_becomes_structured_error(monkeypatch):
165
+ rec: list = []
166
+ _install_mock(
167
+ monkeypatch,
168
+ _recording_handler(rec, {"detail": "Protein not found: ZZZ"}, status=404),
169
+ )
170
+ result = _run(srv.get_protein("ZZZ"))
171
+ assert result == {"error": "Protein not found: ZZZ", "status": 404}
172
+
173
+
174
+ def test_unreachable_api_returns_error_with_hint(monkeypatch):
175
+ # A raised exception would be swallowed by MCP into a generic "Error
176
+ # executing tool" message, so the tool RETURNS a structured error instead.
177
+ def boom(request: httpx.Request) -> httpx.Response:
178
+ raise httpx.ConnectError("connection refused", request=request)
179
+
180
+ _install_mock(monkeypatch, boom)
181
+ result = _run(srv.get_pathway_tree())
182
+ assert result["status"] is None
183
+ assert "Could not reach the ProPaths API" in result["error"]
184
+ assert "PROPATHS_API_URL" in result["error"]
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # _summarize_protein_page — the agent-sized projection (pure)
189
+ # ---------------------------------------------------------------------------
190
+
191
+ def _full_page() -> dict:
192
+ return {
193
+ "main": "ATXN3",
194
+ "protein": {"symbol": "ATXN3", "hgncId": "HGNC:7106"},
195
+ "proteins": ["ATXN3", "VCP", "TP53"],
196
+ "pathways": [
197
+ {"id": "root1", "name": "Proteostasis", "hierarchyLevel": 0, "parentId": None},
198
+ {"id": "p2", "name": "ERAD", "hierarchyLevel": 2, "parentId": "root1"},
199
+ ],
200
+ "interactions": [{
201
+ "id": "i1", "source": "ATXN3", "target": "VCP", "kind": "binds",
202
+ "direction": "bidirectional", "type": "direct", "functionCount": 2,
203
+ "evidenceCount": 3, "supportSummary": "ATXN3 binds VCP",
204
+ "summary": "long...", "mechanism": "M" * 500, "effect": "e",
205
+ "functions": [{"function": "f1", "canonicalPathwayId": "p2",
206
+ "effectDescription": "X" * 500}],
207
+ "evidence": [{"pmid": "1", "keyFinding": "K" * 500}], "pmids": ["1", "2"],
208
+ }],
209
+ "chains": [{
210
+ "id": "c1", "nodes": ["ATXN3", "VCP", "TP53"], "length": 2,
211
+ "netArrow": "activates", "netDirection": "query_to_terminal",
212
+ "terminalNode": "TP53", "terminalEffect": "te", "supportSummary": "chain s",
213
+ "cascadeNarrative": "N" * 500, "hops": [{"mechanism": "M" * 500}],
214
+ }],
215
+ }
216
+
217
+
218
+ def test_summarize_drops_heavy_prose_keeps_headlines_and_resolves_pathways():
219
+ s = srv._summarize_protein_page(_full_page())
220
+ assert s["counts"] == {"interactions": 1, "pathways": 2, "proteins": 3}
221
+ assert s["pathwayRoots"] == ["Proteostasis"]
222
+
223
+ ix = s["interactions"][0]
224
+ assert ix["id"] == "i1" and ix["kind"] == "binds"
225
+ assert ix["supportSummary"] == "ATXN3 binds VCP"
226
+ assert ix["pathways"] == ["ERAD"] # id resolved to name
227
+ for heavy in ("functions", "evidence", "mechanism", "summary", "effect"):
228
+ assert heavy not in ix
229
+
230
+ # chains are SHELVED: the overview must not surface them even if present in the page
231
+ assert "chains" not in s and "chains" not in s["counts"]
232
+
233
+
234
+ def test_summarize_is_smaller_than_raw_page():
235
+ page = _full_page()
236
+ assert len(json.dumps(srv._summarize_protein_page(page))) < len(json.dumps(page))
237
+
238
+
239
+ def test_summarize_handles_edge_with_no_functions():
240
+ page = _full_page()
241
+ page["interactions"][0]["functions"] = []
242
+ s = srv._summarize_protein_page(page)
243
+ assert s["interactions"][0]["pathways"] == []
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # New tools: path routing + pass-through
248
+ # ---------------------------------------------------------------------------
249
+
250
+ def test_types_and_highlights_paths(monkeypatch):
251
+ rec: list = []
252
+ _install_mock(monkeypatch, _recording_handler(rec, {"ok": True}))
253
+ _run(srv.list_interaction_types())
254
+ _run(srv.get_highlights())
255
+ assert [r.url.path for r in rec] == ["/api/interaction-types", "/api/highlights"]
256
+
257
+
258
+ def test_interaction_between_path(monkeypatch):
259
+ rec: list = []
260
+ _install_mock(monkeypatch, _recording_handler(rec, {"source": "ATXN3", "target": "VCP"}))
261
+ out = _run(srv.get_interaction_between("ATXN3", "VCP"))
262
+ assert rec[0].url.path == "/api/edge/ATXN3/VCP"
263
+ assert out == {"source": "ATXN3", "target": "VCP"} # pass-through
264
+
265
+
266
+ def test_list_interactions_forwards_filters(monkeypatch):
267
+ rec: list = []
268
+ _install_mock(monkeypatch, _recording_handler(rec, {"interactions": []}))
269
+ _run(srv.list_interactions("ATXN3", kind="inhibits", min_evidence=3, sort="evidence", limit=10))
270
+ assert rec[0].url.path == "/api/protein/ATXN3/interactions"
271
+ assert dict(rec[0].url.params) == {
272
+ "min_evidence": "3", "sort": "evidence", "order": "desc",
273
+ "limit": "10", "kind": "inhibits"}
274
+
275
+
276
+ def test_get_pathway_path(monkeypatch):
277
+ rec: list = []
278
+ _install_mock(monkeypatch, _recording_handler(rec, {"pathway": {}}))
279
+ _run(srv.get_pathway("abc-123"))
280
+ assert rec[0].url.path == "/api/pathway/abc-123"
281
+
282
+
283
+ def test_export_network_returns_text(monkeypatch):
284
+ rec: list = []
285
+ def handler(request):
286
+ rec.append(request)
287
+ return httpx.Response(200, text="source\ttarget\nATXN3\tVCP\n")
288
+ _install_mock(monkeypatch, handler)
289
+ out = _run(srv.export_network("ATXN3", "tsv"))
290
+ assert rec[0].url.path == "/api/protein/ATXN3/network"
291
+ assert dict(rec[0].url.params) == {"format": "tsv"}
292
+ assert out.startswith("source\ttarget") # raw text, not JSON
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # Static schema (no HTTP) + resources + completion
297
+ # ---------------------------------------------------------------------------
298
+
299
+ def test_describe_schema_is_static_and_offline():
300
+ guide = _run(srv.describe_schema())
301
+ assert {"edgeKinds", "interactionTypes", "directions", "flow"} <= set(guide)
302
+ assert guide["edgeKinds"]["activates"]
303
+ # schema resource returns the same static guide
304
+ assert _run(srv.schema_resource()) == guide
305
+
306
+
307
+ def test_completion_suggests_symbols(monkeypatch):
308
+ from types import SimpleNamespace
309
+ _install_mock(monkeypatch, _recording_handler([], {"results": [{"symbol": "ATXN3"}, {"symbol": "ATXN2"}]}))
310
+ arg = SimpleNamespace(name="symbol", value="ATX")
311
+ result = _run(srv.complete(None, arg, None))
312
+ assert result is not None and result.values == ["ATXN3", "ATXN2"]
313
+
314
+
315
+ def test_completion_ignores_unknown_argument():
316
+ from types import SimpleNamespace
317
+ result = _run(srv.complete(None, SimpleNamespace(name="color", value="red"), None))
318
+ assert result is None