Using Life-Science Ontologies from a Snowflake AI Agent: Gene Ontology and the OLS4 MCP
Introduction
In enterprise data work, the word “ontology” comes up more and more. The idea is to give an AI agent an ontology as the context layer it refers to when it works with company data.
Life science has had ontologies such as the Gene Ontology (GO) for more than 20 years, and anyone can query them through public APIs. EMBL-EBI’s Ontology Lookup Service (OLS4) also offers an MCP server, so an AI agent can look up GO definitions and hierarchies directly.
In this article, I run a GO enrichment in Snowflake on the genes that changed in an RNA-seq experiment (differentially expressed genes, DEGs), and then have an AI agent interpret the result while it looks terms up in OLS4.
Life-science ontologies
In the data-platform world, the usual example is the Palantir Ontology. Its documentation calls it “an operational layer for the organization.” It holds the organization’s objects and links, plus actions and functions. It is an ontology for modelling and running your own business.
Healthcare and life-science ontologies such as GO, the Human Phenotype Ontology (HPO), Mondo and ChEBI are different. They are reference ontologies that define scientific concepts independent of any organization. They give shared identifiers and hierarchies to gene functions, phenotypes, diseases and chemicals, and researchers use them as the standard when they annotate data.
- Purpose: Palantir Ontology represents and runs the business; GO / HPO / Mondo standardize scientific concepts.
- Core: Palantir has objects, links and actions; GO / HPO / Mondo have classes, relations and definitions.
- Relation to data: Palantir holds the actual data; GO / HPO / Mondo are the standard used to annotate data.
The timeline below shows how life-science ontologies and the infrastructure for distributing and querying them developed.
Of these, GO, which appeared in 2000, is widely used in enrichment analysis downstream of RNA-seq. Life-science researchers use ontologies every day.
An architecture for using life-science ontologies from Snowflake
Using life-science ontologies from Snowflake looks like this.
In this article, I build the part where the agent uses the OLS4 MCP, using the Cortex Code Agent SDK. I did not build the semantic view, the ontology gateway or the cache.
A Cortex Agent picks the tool for internal data or the tool for ontologies, depending on the question.
Internal data (left side of the diagram) such as expression, variants and sample information lives in Snowflake tables or Iceberg tables, and a semantic view defines the relationships and metrics between them. Masking policies and row access policies in Horizon Catalog control access here.
For external ontologies (right side), I use the OLS4 MCP server. OLS4 is a service for searching many ontologies, including GO. The MCP server is provided in the official repository and needs no authentication. One server gives access to GO, HPO, Mondo, ChEBI and more.
However, the Cortex Agent MCP connector requires OAuth authentication, so it cannot connect to OLS4 as is, because OLS4 has no authentication. In that case, you register a stored procedure or UDF as a custom tool and call the OLS4 REST API through an external access integration (EAI). Resources without an MCP server, such as Open Targets and ChEMBL, can be called the same way.
Finally, the internal and external results are joined with SQL in Snowflake. If the mapping table between internal IDs and ontology terms also records the ontology release you used, you can track differences after the source is updated.
Looking up GO through the OLS4 MCP server
With the Python MCP SDK, I call searchClasses, fetch and getAncestors to get the definition and hierarchy of GO:0006915 (apoptotic process). I run it in a Snowflake Notebook, so asyncio.run runs in a separate thread to avoid clashing with the notebook's event loop.
import asyncio
import json
from concurrent.futures import ThreadPoolExecutorfrom mcp import ClientSession
from mcp.client.streamable_http import streamable_http_clientOLS4_MCP_URL = "https://www.ebi.ac.uk/ols4/api/mcp"
GO_IRI = "http://purl.obolibrary.org/obo/GO_0006915"def text_of(result):
return "".join(c.text for c in result.content if getattr(c, "type", "") == "text")async def main():
async with streamable_http_client(OLS4_MCP_URL) as streams:
async with ClientSession(streams[0], streams[1]) as s:
await s.initialize()
term = await s.call_tool("searchClasses", {"query": "GO:0006915", "ontologyId": "go"})
definition = await s.call_tool("fetch", {"id": f"go+{GO_IRI}"})
ancestors = await s.call_tool("getAncestors", {"ontologyId": "go", "classIri": GO_IRI})
return term, definition, ancestorsterm, definition, ancestors = ThreadPoolExecutor(1).submit(asyncio.run, main()).result()t = json.loads(text_of(term))["items"][0]
d = json.loads(text_of(definition))
a = json.loads(text_of(ancestors))["items"]print("term:", t["curie"], t["label"][0])
print("definition:", d["text"])
print("parent:", [p["label"][0] for p in t["directParent"]])
print("ancestors:", " → ".join(x["label"][0] for x in a))
term: GO:0006915 apoptotic process
definition: A programmed cell death process which begins when a cell receives an internal (e.g. DNA damage) or external signal ...
parent: ['programmed cell death']
ancestors: occurrent → process → biological_process → cell death → cellular process → programmed cell deathThe official GO label is apoptotic process, so a searchClasses keyword search for apoptosis returns child terms at the top. That is why I look the term up by its GO ID here.
Building DEGs from the airway data
The Bioconductor airway package is RNA-seq data from four human airway smooth muscle cell lines treated with dexamethasone. I use DESeq2 and adjust for the differences between cell lines.
https://www.bioconductor.org/packages/release/data/experiment/html/airway.html
The analysis is done in R.
library(airway); library(DESeq2)
data("airway")
airway$dex <- relevel(airway$dex, ref = "untrt")dds <- DESeqDataSet(airway, design = ~ cell + dex)
dds <- DESeq(dds[rowSums(counts(dds)) >= 10, ])
res <- results(dds, contrast = c("dex", "trt", "untrt"), alpha = 0.05)With padj < 0.05 and |log2FoldChange| > 1, there are 999 DEGs (519 up, 480 down).
Get Tatsuya Koreeda’s stories in your inbox
Join Medium for free to get updates from this writer.
This R code also runs in a Snowflake Notebook (Container Runtime). I create an R and DESeq2 environment with micromamba and call Rscript from a Python cell.
Computing GO enrichment in Snowflake
I load the DESeq2 results into AIRWAY_DEG and define the DEGs as a view.
CREATE OR REPLACE VIEW AIRWAY_DEG_SIG AS
SELECT
*,
IFF(LOG2_FOLD_CHANGE > 0, 'UP', 'DOWN') AS DIRECTION
FROM AIRWAY_DEG
WHERE PADJ < 0.05
AND ABS(LOG2_FOLD_CHANGE) > 1;Counting the view by UP / DOWN gives 519 / 480, the same as in R.
For GO enrichment, I load the gene-to-GO mapping from org.Hs.eg.db into Snowflake and compute it in a Python stored procedure. SQL counts the genes per term, and the p-values come from the hypergeometric distribution. Here is the part that computes the p-values (bh is a function that applies the Benjamini-Hochberg correction).
counts["PVALUE"] = hypergeom.sf(k - 1, N, M, n)
counts["P_ADJUST"] = bh(counts["PVALUE"])With the same settings as clusterProfiler::enrichGO (BP, gene set size 10 to 500, BH), all 4,540 terms matched. Let's look at the top UP terms.
CALL GO_MCP_DEMO.AIRWAY.GO_ENRICHMENT('UP', 5);Run in a Snowsight worksheet, it returns a table with the gene count, p-values and gene list for each term.
Letting the Cortex Code Agent SDK interpret the result
The Cortex Agent MCP connector cannot connect to the OLS4 MCP, which has no authentication, so I use the Cortex Code Agent SDK for the agent. LLM inference runs on Snowflake, and you connect an MCP server just by passing its URL. I limited the tools to SQL against Snowflake and the OLS4 MCP.
from cortex_code_agent_sdk import query, CortexCodeAgentOptionsoptions = CortexCodeAgentOptions(
connection="default",
model="auto",
system_prompt=open("agent/prompts/system_prompt_en.md").read(),
mcp_servers={"ols4": {"type": "http", "url": "https://www.ebi.ac.uk/ols4/api/mcp"}},
allowed_tools=["SQL", "mcp__ols4__*"],
)
async for msg in query(prompt=question, options=options):
...The system prompt tells the agent to take enrichment numbers only from GO_ENRICHMENT and GO definitions and hierarchies only from OLS4. The answer is split into four sections: Statistics, Meaning in Gene Ontology, AI interpretation (hypotheses to verify) and Limitations.
Here is the question.
Get the genes that changed significantly with dexamethasone treatment.
Run a Gene Ontology Biological Process enrichment on them, separately for up-regulated (UP) and down-regulated (DOWN) genes.For the top enriched GO terms, look up their definitions and ontology hierarchy with the OLS4 MCP,
and explain the biological changes caused by dexamethasone treatment.Running it in a terminal, the agent computed the enrichment with SQL and then looked up eight GO terms with the OLS4 MCP searchClasses tool.
Here is the statistics part of the answer. The numbers match the GO_ENRICHMENT results.
These are the definitions and parent terms pulled from OLS4.
Finally, the interpretation based on the statistics and the GO definitions. I asked the agent to mark knowledge that did not come from the tools as “Outside knowledge”.
Because it looked up the hierarchy in OLS4, the agent could tell that GO:0030198 and its parents GO:0043062 and GO:0045229 are one signal from the same 28 genes. From the enrichment table alone, these three look like separate findings.
Summary
- I put the airway DEGs in Snowflake and computed GO enrichment in a stored procedure (matching clusterProfiler).
- I got GO term definitions and hierarchies from the official OLS4 MCP server.
- An agent built with the Cortex Code Agent SDK answered by calling tools in the order DEGs, enrichment, OLS4, interpretation.
The OLS4 MCP server connects with just a URL, so it is an easy way to let an agent use GO definitions and hierarchies when it interprets analysis results.











