Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,25 @@ asyncio.run(main())
| `paper` | `biocontext paper <identifier>` | Fetch detailed paper metadata and abstract by PMID, PMCID, or DOI. |
| `graph` | `biocontext graph <query> [--taxon INT]` | Construct connected biological Knowledge Subgraph (PRD-21). |
| `traverse` | `biocontext traverse <source> <target> [--max-hops INT]` | Find and explain deterministic multi-hop reasoning paths (PRD-22). |
| `api` | `biocontext api [--host HOST] [--port PORT]` | Start standalone BioContext REST API v1 server (PRD-18). |
| `mouse` | `biocontext mouse <MGI_ID>` | Lookup mouse gene model via MGI. |
| `cache` | `biocontext cache [stats|clear]` | Inspect or clear SQLite cache. |
| `serve` | `biocontext serve` | Start stdio MCP server for AI clients. |

---

## 5. REST API v1 Endpoints Reference

Start the server using: `biocontext api --port 8000`

| Method | Endpoint | Description | Example |
| :--- | :--- | :--- | :--- |
| `GET` | `/health` | Service health status and version info | `curl http://127.0.0.1:8000/health` |
| `GET` | `/v1/gene/{symbol}` | Resolve gene symbol to canonical entity | `curl http://127.0.0.1:8000/v1/gene/TP53` |
| `GET` | `/v1/protein/{accession}` | Fetch UniProt protein model | `curl http://127.0.0.1:8000/v1/protein/P04637` |
| `GET` | `/v1/disease/{query}` | Search MONDO disease ontology | `curl http://127.0.0.1:8000/v1/disease/cancer` |
| `GET` | `/v1/graph/{symbol}` | Construct interconnected Knowledge Subgraph | `curl http://127.0.0.1:8000/v1/graph/TP53` |
| `GET` | `/v1/traverse?source=X&target=Y` | Deterministic multi-hop path reasoning | `curl "http://127.0.0.1:8000/v1/traverse?source=TP53&target=cancer"` |



4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "biocontext-mcp"
version = "0.9.0"
version = "1.0.0"
description = "Authoritative Biological Entity Resolution & Contextual Intelligence Framework"
readme = "README.md"
authors = [
Expand All @@ -18,7 +18,7 @@ keywords = [
"uniprot",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Science/Research",
"Topic :: Scientific/Engineering :: Bio-Informatics",
"License :: OSI Approved :: Apache Software License",
Expand Down
172 changes: 170 additions & 2 deletions src/biocontext/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,170 @@
def main() -> None:
print("Hello from biocontext!")
"""BioContext top-level SDK client and core exports (PRD-18)."""

from typing import List, Optional

from biocontext.graph.builder import KnowledgeGraphBuilder
from biocontext.graph.models import SubGraphContext, TraversalResult
from biocontext.graph.traversal import TraversalEngine
from biocontext.resolver import EntityResolver
from biocontext.schemas import (
BatchResolutionSummary,
DiseaseEntity,
FunctionalAnnotation,
GeneEntity,
LiteratureContext,
PathwayContext,
ProteinEntity,
PublicationEntity,
ResolutionContext,
ResolutionResult,
TargetAssociationContext,
)


class BioContext:
"""High-level unified Python SDK client for biological entity intelligence and graph reasoning."""

def __init__(
self,
ncbi_api_key: Optional[str] = None,
email: Optional[str] = None,
tool: Optional[str] = None,
):
self.resolver = EntityResolver(
ncbi_api_key=ncbi_api_key,
email=email,
tool=tool,
)
self.graph_builder = KnowledgeGraphBuilder(resolver=self.resolver)
self.traversal_engine = TraversalEngine(resolver=self.resolver, builder=self.graph_builder)

async def resolve_gene(
self,
query: str,
taxon_id: int = 9606,
chromosome: Optional[str] = None,
locus_type: Optional[str] = None,
) -> ResolutionResult:
"""Resolve a gene symbol or alias to its canonical entity model."""
ctx = None
if chromosome or locus_type:
ctx = ResolutionContext(chromosome=chromosome, locus_type=locus_type)
return await self.resolver.resolve(query=query, taxon_id=taxon_id, context=ctx)

async def get_gene(self, query: str, taxon_id: int = 9606) -> Optional[GeneEntity]:
"""Convenience helper to retrieve resolved canonical GeneEntity directly."""
res = await self.resolve_gene(query, taxon_id=taxon_id)
return res.resolved_entity

async def get_protein(self, accession: str) -> Optional[ProteinEntity]:
"""Retrieve protein metadata from UniProt."""
return await self.resolver.uniprot.fetch_by_accession(accession)

async def get_functional_annotations(
self,
query: str,
taxon_id: int = 9606,
max_terms: int = 25,
) -> Optional[FunctionalAnnotation]:
"""Retrieve QuickGO functional annotations (molecular function, biological process, cellular component)."""
return await self.resolver.annotate_gene(query, taxon_id=taxon_id, max_terms_per_aspect=max_terms)

async def get_pathways(
self,
query: str,
taxon_id: int = 9606,
limit: int = 25,
) -> Optional[PathwayContext]:
"""Retrieve biological pathways from Reactome."""
return await self.resolver.get_pathways(query, taxon_id=taxon_id, limit=limit)

async def get_target_diseases(
self,
query: str,
limit: int = 10,
) -> Optional[TargetAssociationContext]:
"""Retrieve Open Targets disease associations with evidence scores."""
return await self.resolver.get_target_diseases(query, limit=limit)

async def resolve_disease(
self,
query: str,
limit: int = 5,
) -> List[DiseaseEntity]:
"""Search and resolve disease names against the MONDO Disease Ontology."""
return await self.resolver.resolve_disease(query, limit=limit)

async def get_literature(
self,
query: str,
limit: int = 5,
) -> LiteratureContext:
"""Fetch supporting scientific publications from Europe PMC / PubMed."""
return await self.resolver.get_supporting_publications(query, limit=limit)

async def get_publication(
self,
identifier: str,
) -> Optional[PublicationEntity]:
"""Fetch detailed publication metadata by PMID, PMCID, or DOI."""
return await self.resolver.get_publication_details(identifier)

async def resolve_batch(
self,
queries: List[str],
taxon_id: int = 9606,
concurrency: int = 10,
) -> BatchResolutionSummary:
"""Resolve a large list of gene queries concurrently."""
return await self.resolver.resolve_batch(queries, taxon_id=taxon_id, concurrency=concurrency)

async def build_subgraph(
self,
query: str,
taxon_id: int = 9606,
include_pathways: bool = True,
include_go: bool = True,
include_diseases: bool = True,
include_literature: bool = True,
) -> SubGraphContext:
"""Construct an interconnected biological Knowledge Subgraph (PRD-21)."""
return await self.graph_builder.build_gene_subgraph(
query=query,
taxon_id=taxon_id,
include_pathways=include_pathways,
include_go=include_go,
include_diseases=include_diseases,
include_literature=include_literature,
)

async def traverse(
self,
source_query: str,
target_query: str,
max_hops: int = 3,
min_score: float = 0.0,
) -> TraversalResult:
"""Perform deterministic multi-hop reasoning between entities (PRD-22)."""
return await self.traversal_engine.traverse(
source_query=source_query,
target_query=target_query,
max_hops=max_hops,
min_score=min_score,
)


__all__ = [
"BioContext",
"EntityResolver",
"GeneEntity",
"ProteinEntity",
"ResolutionResult",
"FunctionalAnnotation",
"PathwayContext",
"TargetAssociationContext",
"DiseaseEntity",
"LiteratureContext",
"PublicationEntity",
"SubGraphContext",
"TraversalResult",
]
107 changes: 107 additions & 0 deletions src/biocontext/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Lightweight REST API v1 server for BioContext (PRD-18).

Runs standalone using Python's standard library http.server and asyncio,
ensuring zero extra heavy dependencies (FastAPI/Starlette not required).
"""

import asyncio
import json
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, HTTPServer
import urllib.parse
from typing import Any, Dict

from biocontext import BioContext
from biocontext.logging import get_logger

logger = get_logger("api")


class BioContextAPIHandler(BaseHTTPRequestHandler):
"""Handler for REST API v1 endpoints."""

client: BioContext = None # Injected during startup
loop: asyncio.AbstractEventLoop = None

def _send_json(self, data: Any, status: int = HTTPStatus.OK):
body = json.dumps(data, indent=2).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)

def _send_error_json(self, message: str, status: int = HTTPStatus.BAD_REQUEST):
self._send_json({"error": message, "status_code": status}, status=status)

def do_GET(self):
parsed_url = urllib.parse.urlparse(self.path)
path = parsed_url.path.rstrip("/")
query_params = urllib.parse.parse_qs(parsed_url.query)

# Healthcheck
if path == "/health" or path == "":
return self._send_json({"status": "ok", "version": "1.0.0", "service": "BioContext API v1"})

# GET /v1/gene/{symbol}
if path.startswith("/v1/gene/"):
symbol = path.replace("/v1/gene/", "").strip()
if not symbol:
return self._send_error_json("Gene symbol is required")

res = self.loop.run_until_complete(self.client.resolve_gene(symbol))
return self._send_json(res.model_dump())

# GET /v1/protein/{accession}
if path.startswith("/v1/protein/"):
accession = path.replace("/v1/protein/", "").strip()
protein = self.loop.run_until_complete(self.client.get_protein(accession))
if not protein:
return self._send_error_json(f"Protein '{accession}' not found", status=HTTPStatus.NOT_FOUND)
return self._send_json(protein.model_dump())

# GET /v1/disease/{query}
if path.startswith("/v1/disease/"):
disease_q = path.replace("/v1/disease/", "").strip()
diseases = self.loop.run_until_complete(self.client.resolve_disease(disease_q))
return self._send_json([d.model_dump() for d in diseases])

# GET /v1/graph/{symbol}
if path.startswith("/v1/graph/"):
symbol = path.replace("/v1/graph/", "").strip()
subgraph = self.loop.run_until_complete(self.client.build_subgraph(symbol))
return self._send_json(subgraph.model_dump())

# GET /v1/traverse?source=TP53&target=cancer
if path == "/v1/traverse":
source = query_params.get("source", [""])[0].strip()
target = query_params.get("target", [""])[0].strip()
max_hops = int(query_params.get("max_hops", [3])[0])

if not source or not target:
return self._send_error_json("Both 'source' and 'target' query parameters are required")

res = self.loop.run_until_complete(self.client.traverse(source, target, max_hops=max_hops))
return self._send_json(res.model_dump())

return self._send_error_json("Endpoint not found", status=HTTPStatus.NOT_FOUND)


def run_api_server(host: str = "127.0.0.1", port: int = 8000):
"""Launch the lightweight REST API v1 server."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
client = BioContext()

BioContextAPIHandler.client = client
BioContextAPIHandler.loop = loop

server = HTTPServer((host, port), BioContextAPIHandler)
logger.info(f"BioContext REST API v1 listening at http://{host}:{port}")
try:
server.serve_forever()
except KeyboardInterrupt:
logger.info("Shutting down REST API server...")
finally:
server.server_close()
3 changes: 3 additions & 0 deletions src/biocontext/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,9 @@ def main():

if args.command == "server":
mcp.run(transport="stdio")
elif args.command == "api":
from biocontext.api import run_api_server
run_api_server(host=args.host, port=args.port)
elif args.command == "bench":
import pytest
sys.exit(pytest.main(["tests/test_benchmark.py", "-v"]))
Expand Down
9 changes: 9 additions & 0 deletions src/biocontext/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,15 @@ def get_headers(cls, email: Optional[str] = None) -> Dict[str, str]:
{"flags": ["--min-score"], "type": float, "default": 0.0, "help": "Minimum cumulative score (default: 0.0)"}
]
},
{
"name": "api",
"help": "Start standalone BioContext REST API v1 HTTP server (PRD-18)",
"arguments": [
{"flags": ["--host"], "default": "127.0.0.1", "help": "Host address (default: 127.0.0.1)"},
{"flags": ["--port"], "type": int, "default": 8000, "help": "Port number (default: 8000)"}
]
},




Expand Down
Loading
Loading