Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1dc51a8
[ODM-13126] Add json file
genestack-okunitsyn Mar 4, 2026
99dc1fb
[ODM-13126] Remove redundant annotations
genestack-okunitsyn Mar 4, 2026
fc4f921
[ODM-13126] All specs in one
genestack-okunitsyn Mar 4, 2026
381bc0c
[ODM-13126] Dummy ctrl+x and ctrl+v
genestack-okunitsyn Mar 4, 2026
d0d5648
[ODM-13126] Finish moving spec
genestack-okunitsyn Mar 4, 2026
e7acdd8
[ODM-13126] Remove schemas directory with empty files
genestack-okunitsyn Mar 4, 2026
a569cdd
[ODM-13126] Downgrade version of spec
genestack-okunitsyn Mar 4, 2026
6c454a3
[ODM-13126] Simple mcp server
genestack-okunitsyn Mar 5, 2026
52c23ab
[ODM-13126] Add to Earthfile
genestack-okunitsyn Mar 5, 2026
4ec092d
[ODM-13126] Remove json format
genestack-okunitsyn Mar 5, 2026
a33156f
[ODM-13126] Add json file
genestack-okunitsyn Mar 4, 2026
c459796
[ODM-13126] Remove redundant annotations
genestack-okunitsyn Mar 4, 2026
3202c85
[ODM-13126] All specs in one
genestack-okunitsyn Mar 4, 2026
bdfba6c
[ODM-13126] Dummy ctrl+x and ctrl+v
genestack-okunitsyn Mar 4, 2026
1dbd2a9
[ODM-13126] Finish moving spec
genestack-okunitsyn Mar 4, 2026
8dedd7f
[ODM-13126] Remove schemas directory with empty files
genestack-okunitsyn Mar 4, 2026
9c30d5a
[ODM-13126] Downgrade version of spec
genestack-okunitsyn Mar 4, 2026
585725b
[ODM-13126] Simple mcp server
genestack-okunitsyn Mar 5, 2026
dd2b244
[ODM-13126] Add to Earthfile
genestack-okunitsyn Mar 5, 2026
e70aeea
[ODM-13126] Remove json format
genestack-okunitsyn Mar 5, 2026
77a7c3d
Merge branch 'feature/ODM-13126-mcp-server' of github.com:genestack/o…
piaxar Mar 9, 2026
4fc1d33
Expose odmApi yaml file as a resource
piaxar Mar 10, 2026
a1717d0
[ODM-13126] Fix spec resource declaration
genestack-okunitsyn Mar 10, 2026
8286574
Expose tools
piaxar Mar 10, 2026
ecafe25
Add alternative mcp (won't be deployed) scaffolding
piaxar Mar 10, 2026
1fa51cf
minor fix
piaxar Mar 10, 2026
9dcd1c9
Alternative MCP server for documentation discovery
piaxar Mar 10, 2026
a4ebec3
pass-through authorization with Genestack-api-token
piaxar Mar 11, 2026
3354bd4
Remove documentation endpoing
piaxar Mar 11, 2026
999edee
Limit exposure to only user's endpoints
piaxar Mar 11, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ node_modules

# Ignore merged file and one downloaded for processor-controller
/openapi/v1/odmApi.yaml
/openapi/v1/odmApi.json
/openapi/v1/processorsController.yaml
32 changes: 30 additions & 2 deletions Earthfile
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ swagger:
COPY +build/v1 /usr/share/nginx/html/yaml/
COPY openapi/swagger/fs /

RUN rm -f /usr/share/nginx/html/yaml/odmApi.yaml
RUN rm -f /usr/share/nginx/html/yaml/odm.yaml /usr/share/nginx/html/yaml/processorsController.yaml
RUN apk add bash --no-cache && \
rewrite_entrypoint.sh && \
apk del bash && \
Expand All @@ -169,17 +169,45 @@ swagger:
stoplight:
FROM nginxinc/nginx-unprivileged:1.29.5-alpine

COPY +build/v1/schemas /usr/share/nginx/html/schemas/
COPY +build/v1/odmApi.yaml /usr/share/nginx/html/
COPY openapi/stoplight/fs /

ARG --required OPENAPI_VERSION
SAVE IMAGE --push ${HARBOR_DOCKER_REGISTRY}/stoplight:${OPENAPI_VERSION}
SAVE IMAGE --push ${HARBOR_DOCKER_REGISTRY}/stoplight:latest

openapi-mcp-server:
FROM astral/uv:0.10.7-python3.13-trixie-slim

RUN groupadd --system --gid 999 nonroot \
&& useradd --system --gid 999 --uid 999 --create-home nonroot
USER nonroot

WORKDIR /app

ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV UV_SYSTEM_PYTHON=1
ENV UV_LINK_MODE=copy

COPY mcp-server/pyproject.toml mcp-server/uv.lock .
RUN uv sync --frozen --no-cache --no-dev

COPY mcp-server/src /app/src
COPY +build/v1/odmApi.yaml /app/src/.

# Run the application using uv
ENTRYPOINT ["uv"]
CMD ["run", "src/main.py"]

ARG --required OPENAPI_VERSION
SAVE IMAGE --push ${HARBOR_DOCKER_REGISTRY}/openapi-mcp-server:${OPENAPI_VERSION}
SAVE IMAGE --push ${HARBOR_DOCKER_REGISTRY}/openapi-mcp-server:latest

main:
BUILD +swagger
BUILD +stoplight
BUILD +openapi-mcp-server
BUILD +docs
BUILD +r-api-client
BUILD +python-api-client
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ abstract class MergeSpecifications : DefaultTask() {
@TaskAction
fun merge() {
val objectMapper = ObjectMapper(YAMLFactory())

val mergedNode = inputFiles
.get().map { it.asFile }
.filterNot { it == outputFile.get().asFile }
.map { objectMapper.readTree(it) }
.reduce { acc, node -> objectMapper.updateValue(acc, node) }

objectMapper.writeValue(outputFile.get().asFile, mergedNode)
}
}
2 changes: 2 additions & 0 deletions mcp-server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.venv
.env
1 change: 1 addition & 0 deletions mcp-server/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
Empty file added mcp-server/README.md
Empty file.
10 changes: 10 additions & 0 deletions mcp-server/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[project]
name = "mcp-server"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"fastmcp>=3.1.0",
"httpx>=0.28.1",
]
86 changes: 86 additions & 0 deletions mcp-server/src/alternative_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import os
from pathlib import Path

import httpx
import yaml
from fastmcp import FastMCP
from pathlib import Path

odm_url = os.environ.get("ODM_URL", 'https://develop-oak.dev.gs.team') # pre-defined value for local testing
odm_token = os.environ.get("ODM_TOKEN", "tknRoot")
server_host = os.environ.get("SERVER_HOST", "0.0.0.0")
server_port = os.environ.get("SERVER_PORT", 9080)

# Load OpenAPI spec from the same directory as this file
spec_path = Path(__file__).with_name("odmApi.yaml")
with spec_path.open("r", encoding="utf-8") as fh:
openapi_spec = yaml.safe_load(fh)

user_tags: list[str] = [tag["name"] for tag in openapi_spec["tags"] if "as User" in tag["name"]]
curator_tags: list[str] = [tag["name"] for tag in openapi_spec["tags"] if "as Curator" in tag["name"]]

# Map $ref string -> schema dict, e.g. "#/components/schemas/Study" -> {...}
schemas_by_ref: dict[str, dict] = {
f"#/components/schemas/{name}": schema
for name, schema in openapi_spec["components"]["schemas"].items()
}

# Map tag -> list of {endpoint, method, summary, operationId}
methods_by_tag: dict[str, list[dict]] = {}
for _path, _path_item in openapi_spec["paths"].items():
for _method, _operation in _path_item.items():
if not isinstance(_operation, dict):
continue
for _tag in _operation.get("tags", []):
methods_by_tag.setdefault(_tag, []).append({
"endpoint": _path,
"method": _method,
"summary": _operation.get("summary", ""),
"operationId": _operation.get("operationId", ""),
})

mcp = FastMCP(
name="ODM API docs MCP Server",
instructions="""
This server provides documentation on using the ODM API for data querying, retrieval, import (ingestion), and curation.
Its main purpose is to provide the LLM with correct schemas of endpoints.
Call get_odm_api_overview() to get more details about the API.
"""
)

type Tag = str

@mcp.tool(description="Fetch url of the ODM API server")
def get_base_url() -> str:
return odm_url

@mcp.tool(description="Get an overview of functions available in ODM")
def get_odm_api_overview() -> str:
return """
- You can explore and retrieve data from the ODM using the API endpoints. These endpoints are marked with suffix "as User". Get a list of all tools by calling "list_query_retrieval_endpoints" tool.
- You can also create studies and curate data. The main functions available are Create a new study and Curate Data. These endpoints are marked with suffix "as Curator". Get a list of all tools by calling "list_import_curation_endpoints" tool.
"""

@mcp.tool(description="List all available querying and retrieval endpoint groups (tags)")
def list_query_retrieval_endpoints() -> list[Tag]:
return user_tags

@mcp.tool(description="List all available import and curation endpoint groups (tags)")
def list_import_curation_endpoints() -> list[Tag]:
return curator_tags

@mcp.tool(description='List all paths with descriptions that belong to a specific group (tag) (e.g. "Study SPoT as User")')
def list_methods_in_group(group: Tag) -> list[dict]:
return methods_by_tag.get(group, [])

@mcp.tool(description='Get operations, parameters, responses and schemas of an endpoint (e.g. "/api/v1/as-curator/studies/{id}")')
def get_path_item(endpoint: str) -> dict:
return openapi_spec["paths"].get(endpoint, {})

@mcp.tool(description='Get data schema definition by $ref (e.g. "#/components/schemas/Study")')
def get_schema_by_ref(ref: str) -> dict:
return schemas_by_ref.get(ref, {})


if __name__ == "__main__":
mcp.run(transport="streamable-http", host=server_host, port=server_port)
91 changes: 91 additions & 0 deletions mcp-server/src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import os
import json
from contextvars import ContextVar
from pathlib import Path
from typing import Any

import httpx
import yaml
from fastmcp import FastMCP
from fastmcp.server.openapi import RouteMap, MCPType
from fastmcp.server.middleware import Middleware, MiddlewareContext, CallNext
from fastmcp.server.dependencies import get_http_headers

# Per-request storage for the API token.
# ContextVar is asyncio-safe: each concurrent request gets its own isolated value.
current_token: ContextVar[str] = ContextVar("current_token", default="")

odm_url = os.environ.get("ODM_URL", "https://develop-ip.dev.gs.team")
server_host = os.environ.get("SERVER_HOST", "0.0.0.0")
server_port = int(os.environ.get("SERVER_PORT", 8080))

spec_path = Path(__file__).with_name("odmApi.yaml")
with spec_path.open("r", encoding="utf-8") as fh:
openapi_spec = yaml.safe_load(fh)


class DynamicTokenAuth(httpx.Auth):
"""Injects the per-request token into every outgoing ODM API call.

Reads from current_token ContextVar, so each concurrent MCP request
carries its own token without shared state.
"""
def auth_flow(self, request: httpx.Request):
token = current_token.get()
if token:
request.headers["Genestack-API-Token"] = token
yield request


class TokenExtractMiddleware(Middleware):
"""Extracts the API token from the incoming HTTP request and stores it
in current_token for the duration of the MCP message handling.
"""
async def on_message(self, context: MiddlewareContext[Any], call_next: CallNext) -> Any:
headers = get_http_headers(include={"x-genestack-api-token", "authorization"})
token = (
headers.get("x-genestack-api-token")
or headers.get("authorization", "").removeprefix("Bearer ")
)
token_var = current_token.set(token)
try:
return await call_next(context)
finally:
# Reset to previous value to avoid leaking across reused tasks.
current_token.reset(token_var)


# Single shared client — connection pooling is safe because auth is per-request via DynamicTokenAuth.
client = httpx.AsyncClient(base_url=odm_url, auth=DynamicTokenAuth())

mcp = FastMCP.from_openapi(
name="Openapi MCP Server",
openapi_spec=openapi_spec,
client=client,
middleware=[TokenExtractMiddleware()],
route_maps=[
# Include "as User" endpoints
RouteMap(
pattern=r"^/api/v1/as-user/.*",
mcp_type=MCPType.TOOL,
),
# Include "as Curator" endpoints
# RouteMap(
# pattern=r"^/api/v1/as-curator/.*",
# mcp_type=MCPType.TOOL,
# ),
# exclude anything else
RouteMap(
pattern=r".*",
mcp_type=MCPType.EXCLUDE,
)
]
)

@mcp.tool(description="Returns url of ODM API server")
def get_base_url() -> str:
return odm_url


if __name__ == "__main__":
mcp.run(transport="streamable-http", host=server_host, port=server_port)
Loading