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.
- propaths_mcp-0.1.0/.gitattributes +2 -0
- propaths_mcp-0.1.0/.gitignore +9 -0
- propaths_mcp-0.1.0/LICENSE +21 -0
- propaths_mcp-0.1.0/PKG-INFO +107 -0
- propaths_mcp-0.1.0/README.md +88 -0
- propaths_mcp-0.1.0/pyproject.toml +33 -0
- propaths_mcp-0.1.0/src/propaths_mcp/__init__.py +3 -0
- propaths_mcp-0.1.0/src/propaths_mcp/__main__.py +6 -0
- propaths_mcp-0.1.0/src/propaths_mcp/server.py +455 -0
- propaths_mcp-0.1.0/tests/test_tools.py +318 -0
|
@@ -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,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
|