Skip to content

Commit dc1fab2

Browse files
authored
Support per-request API keys and update staging/autopush config (#140)
1 parent 786396d commit dc1fab2

File tree

14 files changed

+165
-32
lines changed

14 files changed

+165
-32
lines changed

deploy/autopush.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ steps:
5858
docker push gcr.io/$PROJECT_ID/datacommons-mcp-server:autopush-$(cat _version.txt)
5959
6060
# 4. Deploy to Cloud Run
61+
# NOTE: Using prod search root because autopush search root (autopush.datacommons.org) requires a Google login.
62+
# The search root will completely go away once we start using the new resolve endpoint.
63+
# TODO: Remove this once we start using the new resolve endpoint.
6164
- name: gcr.io/cloud-builders/gcloud
6265
entrypoint: bash
6366
args:
@@ -69,7 +72,10 @@ steps:
6972
--platform=managed \
7073
--no-allow-unauthenticated \
7174
--set-env-vars=MCP_VERSION=$(cat _version.txt) \
72-
--set-secrets=DC_API_KEY=dc-api-key-for-mcp:latest \
75+
--set-env-vars=DC_API_ROOT=https://autopush.api.datacommons.org/v2 \
76+
--set-env-vars=DC_SEARCH_ROOT=https://datacommons.org \
77+
--set-env-vars=DC_API_KEY_VALIDATION_ROOT=https://autopush.api.datacommons.org \
78+
--set-secrets=DC_API_KEY=dc-autopush-api-key-for-mcp:latest \
7379
--service-account=datacommons-mcp-server@datcom-mixer-autopush.iam.gserviceaccount.com \
7480
--project=datcom-mixer-autopush
7581

deploy/staging.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ steps:
6767
--platform=managed \
6868
--no-allow-unauthenticated \
6969
--set-env-vars=MCP_VERSION=$(cat _version.txt) \
70-
--set-secrets=DC_API_KEY=dc-api-key-for-mcp:latest \
70+
--set-env-vars=DC_API_ROOT=https://staging.api.datacommons.org/v2 \
71+
--set-env-vars=DC_SEARCH_ROOT=https://staging.datacommons.org \
72+
--set-env-vars=DC_API_KEY_VALIDATION_ROOT=https://staging.api.datacommons.org \
73+
--set-secrets=DC_API_KEY=dc-staging-api-key-for-mcp:latest \
7174
--service-account=datacommons-mcp-server@datcom-mixer-staging.iam.gserviceaccount.com \
7275
--project=datcom-mixer-staging
7376

packages/datacommons-mcp/.env.sample

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,20 @@ DC_TYPE=base
4444
# DC_SEARCH_SCOPE=base_and_custom
4545

4646
# =============================================================================
47-
# LOCAL ROOTS (optional, base DC only)
47+
# NON-PROD ROOTS (optional, base DC only)
4848
# =============================================================================
4949

50-
# Use these variables to run the server against local instances
50+
# Use these variables to run the server against non-prod (autopush, staging) or local instances
5151
# of the Data Commons API and Search endpoints.
5252
# When using a local instance, you may also need to use the
5353
# --skip-api-key-validation command-line flag if running without a DC_API_KEY.
5454

55-
# Root URL for a local Data Commons API (mixer) instance
55+
# Root URL for a non-prod or local Data Commons API (mixer) instance
5656
# DC_API_ROOT=http://localhost:8081/v2
5757

58-
# Root URL for a local Data Commons Search (website) instance
58+
# Root URL for a non-prod or local Data Commons Search (website) instance
5959
# DC_SEARCH_ROOT=http://localhost:8080
60+
61+
# Root URL for Data Commons API key validation
62+
# Configure for non-prod environments
63+
# DC_API_KEY_VALIDATION_ROOT=https://api.datacommons.org

packages/datacommons-mcp/datacommons_mcp/cli.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
import click
66
from click.core import Context, Option, ParameterSource
77
from dotenv import find_dotenv, load_dotenv
8+
from starlette.middleware import Middleware
89

910
from .exceptions import APIKeyValidationError, InvalidAPIKeyError
11+
from .middleware import APIKeyMiddleware
1012
from .utils import validate_api_key
1113
from .version import __version__
1214

@@ -59,7 +61,10 @@ def _run_api_key_validation(ctx: Context, *, skip_validation: bool) -> None:
5961
api_key = os.getenv("DC_API_KEY")
6062
if not api_key:
6163
raise InvalidAPIKeyError("DC_API_KEY is not set.")
62-
validate_api_key(api_key)
64+
validation_api_root = os.getenv(
65+
"DC_API_KEY_VALIDATION_ROOT", "https://api.datacommons.org"
66+
).rstrip("/")
67+
validate_api_key(api_key, validation_api_root)
6368
except (InvalidAPIKeyError, APIKeyValidationError) as e:
6469
click.echo(str(e), err=True)
6570
click.echo(
@@ -80,7 +85,13 @@ def _run_http_server(host: str, port: int) -> None:
8085
click.echo(f"Server URL: http://{host}:{port}")
8186
click.echo(f"Streamable HTTP endpoint: http://{host}:{port}/mcp")
8287
click.echo("Press CTRL+C to stop")
83-
mcp.run(host=host, port=port, transport="streamable-http", stateless_http=True)
88+
mcp.run(
89+
host=host,
90+
port=port,
91+
transport="streamable-http",
92+
stateless_http=True,
93+
middleware=[Middleware(APIKeyMiddleware)],
94+
)
8495

8596

8697
def _run_stdio_server() -> None:
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import logging
2+
from collections.abc import Awaitable, Callable
3+
4+
from datacommons_client import use_api_key
5+
from starlette.middleware.base import BaseHTTPMiddleware
6+
from starlette.requests import Request
7+
from starlette.responses import Response
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
class APIKeyMiddleware(BaseHTTPMiddleware):
13+
"""
14+
Middleware to extract X-API-Key header and set it as the override API key
15+
for the Data Commons client context.
16+
"""
17+
18+
async def dispatch(
19+
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
20+
) -> Response:
21+
api_key = request.headers.get("X-API-Key")
22+
if api_key:
23+
logger.debug("Received X-API-Key header, applying override.")
24+
try:
25+
with use_api_key(api_key):
26+
return await call_next(request)
27+
except Exception as e:
28+
# We log and re-raise to ensure we don't swallow application errors,
29+
# but we want to know if the context manager itself failed.
30+
logger.error("Error during API key override context propagation: %s", e)
31+
raise
32+
else:
33+
return await call_next(request)

packages/datacommons-mcp/datacommons_mcp/utils.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@
2222

2323
logger = logging.getLogger(__name__)
2424

25-
VALIDATION_API_URL = "https://api.datacommons.org/v2/node?nodes=geoId/06"
25+
VALIDATION_API_PATH = "/v2/node?nodes=geoId/06"
2626

2727

28-
def validate_api_key(api_key: str) -> None:
28+
def validate_api_key(api_key: str, validation_api_root: str) -> None:
2929
"""
3030
Validates the Data Commons API key by making a simple API call.
3131
@@ -36,9 +36,12 @@ def validate_api_key(api_key: str) -> None:
3636
InvalidAPIKeyError: If the API key is invalid or has expired.
3737
APIKeyValidationError: For other network-related validation errors.
3838
"""
39+
validation_api_url = f"{validation_api_root}{VALIDATION_API_PATH}"
40+
logger.info("Validating API key with URL: %s", validation_api_url)
41+
3942
try:
4043
response = requests.get(
41-
VALIDATION_API_URL,
44+
validation_api_url,
4245
headers={"X-API-Key": api_key},
4346
timeout=10, # 10-second timeout
4447
)

packages/datacommons-mcp/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ dependencies = [
99
"uvicorn",
1010
"fastmcp",
1111
"requests",
12-
"datacommons-client",
12+
"datacommons-client>=2.1.5",
1313
"pydantic>=2.11.7",
1414
"pydantic-settings",
1515
"python-dateutil>=2.9.0.post0",

packages/datacommons-mcp/tests/test_cli.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ def test_serve_http_accepts_http_options(mock_run):
110110
port=9090,
111111
transport="streamable-http",
112112
stateless_http=True,
113+
middleware=mock.ANY,
113114
)
114115

115116

@@ -146,5 +147,5 @@ def test_cli_loads_dotenv_end_to_end(mock_validate, mock_run):
146147
result = runner.invoke(cli, ["serve", "http"])
147148
assert result.exit_code == 0
148149
# Verify validate_api_key was called with the key from .env
149-
mock_validate.assert_called_with("generated-key")
150+
mock_validate.assert_called_with("generated-key", "https://api.datacommons.org")
150151
mock_run.assert_called_once()
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import pytest
4+
from datacommons_mcp.middleware import APIKeyMiddleware
5+
from starlette.applications import Starlette
6+
from starlette.middleware import Middleware
7+
from starlette.responses import JSONResponse
8+
from starlette.routing import Route
9+
from starlette.testclient import TestClient
10+
11+
12+
async def homepage(request): # noqa: ARG001
13+
return JSONResponse({"status": "ok"})
14+
15+
16+
@pytest.fixture
17+
def client():
18+
# Define a simple Starlette app with the middleware
19+
middleware = [Middleware(APIKeyMiddleware)]
20+
routes = [Route("/", homepage)]
21+
app = Starlette(routes=routes, middleware=middleware)
22+
return TestClient(app)
23+
24+
25+
def test_api_key_header_present(client):
26+
"""Verify use_api_key is called when X-API-Key header is present."""
27+
with patch("datacommons_mcp.middleware.use_api_key") as mock_use_api_key:
28+
# Configuration for the mock context manager
29+
mock_context_manager = MagicMock()
30+
mock_use_api_key.return_value.__enter__.return_value = mock_context_manager
31+
32+
headers = {"X-API-Key": "test-key-123"}
33+
response = client.get("/", headers=headers)
34+
35+
assert response.status_code == 200
36+
mock_use_api_key.assert_called_once_with("test-key-123")
37+
# Ensure the context manager was actually entered
38+
mock_use_api_key.return_value.__enter__.assert_called_once()
39+
mock_use_api_key.return_value.__exit__.assert_called_once()
40+
41+
42+
def test_api_key_header_missing(client):
43+
"""Verify use_api_key is NOT called when X-API-Key header is missing."""
44+
with patch("datacommons_mcp.middleware.use_api_key") as mock_use_api_key:
45+
response = client.get("/")
46+
47+
assert response.status_code == 200
48+
mock_use_api_key.assert_not_called()
49+
50+
51+
def test_api_key_header_empty(client):
52+
"""Verify use_api_key is NOT called when X-API-Key header is empty."""
53+
with patch("datacommons_mcp.middleware.use_api_key") as mock_use_api_key:
54+
headers = {"X-API-Key": ""}
55+
response = client.get("/", headers=headers)
56+
57+
assert response.status_code == 200
58+
mock_use_api_key.assert_not_called()

packages/datacommons-mcp/tests/test_utils.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818
from datacommons_mcp.data_models.observations import DateRange
1919
from datacommons_mcp.exceptions import APIKeyValidationError, InvalidAPIKeyError
2020
from datacommons_mcp.utils import (
21-
VALIDATION_API_URL,
21+
VALIDATION_API_PATH,
2222
filter_by_date,
2323
validate_api_key,
2424
)
2525

26+
TEST_ROOT = "https://test.api.datacommons.org"
27+
2628

2729
class TestFilterByDate:
2830
@pytest.fixture
@@ -57,20 +59,23 @@ def test_empty_result(self, observations):
5759

5860
class TestValidateAPIKey:
5961
def test_validate_api_key_success(self, requests_mock):
60-
requests_mock.get(VALIDATION_API_URL, status_code=200)
62+
url = f"{TEST_ROOT}{VALIDATION_API_PATH}"
63+
requests_mock.get(url, status_code=200)
6164
api_key_to_test = "my-test-api-key"
62-
validate_api_key(api_key_to_test) # Should not raise an exception
65+
validate_api_key(api_key_to_test, TEST_ROOT) # Should not raise an exception
6366
assert requests_mock.last_request.headers["X-API-Key"] == api_key_to_test
6467

6568
def test_validate_api_key_invalid(self, requests_mock):
66-
requests_mock.get(VALIDATION_API_URL, status_code=403)
69+
url = f"{TEST_ROOT}{VALIDATION_API_PATH}"
70+
requests_mock.get(url, status_code=403)
6771
with pytest.raises(InvalidAPIKeyError):
68-
validate_api_key("invalid_key")
72+
validate_api_key("invalid_key", TEST_ROOT)
6973

7074
def test_validate_api_key_network_error(self, requests_mock):
75+
url = f"{TEST_ROOT}{VALIDATION_API_PATH}"
7176
requests_mock.get(
72-
VALIDATION_API_URL,
77+
url,
7378
exc=requests.exceptions.RequestException("Network error"),
7479
)
7580
with pytest.raises(APIKeyValidationError):
76-
validate_api_key("any_key")
81+
validate_api_key("any_key", TEST_ROOT)

0 commit comments

Comments
 (0)