Skip to content
Closed
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
15 changes: 15 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(grep \"^tags:\" /Users/i.panchenko/Work/openapi/openapi/v1/*.yaml -l)",
"Bash(done)",
"Bash(grep -n \"^ [A-Z][A-Za-z]*:$\" /Users/i.panchenko/Work/openapi/mcp-server/src/odmApi.yaml)",
"Bash(grep -n \"^ [A-Z][A-Za-z]*:$\" /Users/i.panchenko/Work/openapi/mcp-server/src/odmApi_old.yaml)",
"Bash(grep -c \"^ [A-Z][A-Za-z]*:$\" /Users/i.panchenko/Work/openapi/mcp-server/src/odmApi.yaml)",
"Bash(grep -c \"^ [A-Z][A-Za-z]*:$\" /Users/i.panchenko/Work/openapi/mcp-server/src/odmApi_old.yaml)",
"Bash(grep \"^ [A-Z][A-Za-z]*:$\" /Users/i.panchenko/Work/openapi/mcp-server/src/odmApi.yaml)",
"Bash(grep \"^ [A-Z][A-Za-z]*:$\" /Users/i.panchenko/Work/openapi/mcp-server/src/odmApi_old.yaml)",
"Read(//Users/i.panchenko/Work/openapi/**)"
]
}
}
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,10 @@ node_modules
# Ignore merged file and one downloaded for processor-controller
/openapi/v1/odmApi.yaml
/openapi/v1/processorsController.yaml

# Ignore python cache files and env from mcp-server
.venv/*
__pycache__/*
mcp-server/src/schemas/*
mcp-server/src/odmApi.yaml
mcp-server/src/odmApi_old.yaml
32 changes: 32 additions & 0 deletions Earthfile
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,41 @@ stoplight:
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/schemas /app/src/schemas
COPY +build/v1/odmApi.yaml /app/src/.
RUN uv run src/enrich_spec.py
RUN rm -rf /app/src/schemas

# 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
1 change: 1 addition & 0 deletions mcp-server/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.14
Empty file added mcp-server/README.md
Empty file.
11 changes: 11 additions & 0 deletions mcp-server/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[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",
"pyyaml>=6.0.3",
]
32 changes: 32 additions & 0 deletions mcp-server/src/enrich_spec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from pathlib import Path

import yaml


def _resolve_node(node: object, base_dir: Path) -> object:
if isinstance(node, dict):
if "$ref" in node and len(node) == 1:
ref: str = node["$ref"]
if not ref.startswith("#"):
schema_file = (base_dir / ref).resolve()
with schema_file.open("r", encoding="utf-8") as fh:
loaded = yaml.safe_load(fh)
return _resolve_node(loaded, schema_file.parent)
return {k: _resolve_node(v, base_dir) for k, v in node.items()}
if isinstance(node, list):
return [_resolve_node(item, base_dir) for item in node]
return node


if __name__ == "__main__":
spec_path = Path(__file__).with_name("odmApi.yaml")
with spec_path.open("r", encoding="utf-8") as fh:
spec: dict = yaml.safe_load(fh)

schemas: dict = spec.get("components", {}).get("schemas", {})
for name, value in list(schemas.items()):
schemas[name] = _resolve_node(value, spec_path.parent)

with spec_path.open("w", encoding="utf-8") as fh:
yaml.dump(spec, fh, allow_unicode=True, default_flow_style=False, sort_keys=False)
print(f"Enriched {spec_path}")
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
Loading