Initial commit: ag-bids MCP server
Exposes live + historical ag-bids commodity data (from the ag-monitor service at agbids.paul.farm) as MCP tools, sitting behind MetaMCP at https://mcp.jpaul.io/metamcp/ag-bids/mcp. Pattern mirrors zerto-docs-rag with one addition: HTTP Basic auth in front of the streamable-HTTP transport so namespace guessers can't reach the tools. Stdio transport is unaffected (used by local Claude Desktop dev). Tools (markdown returns, ~15 LOC each): best_local_bid(commodity) — where to sell corn/soy/wheat today, for the current calendar month only current_lime_price() — latest lime quotes ($/ton) current_input_price(product?) — MAP / Potash / Lime latest_prices(...) — filtered snapshot price_history(...) — per-(source,delivery) trend list_sources / list_commodities / list_deliveries source_health() — healthy / stale / down buckets todays_summary() — same shape as morning brief snapshot Data path: ag-bids-mcp -> X-API-Key -> /api/data/* on ag-monitor (reuses BRIEF_API_KEY). Tests: 24 covering the httpx client, markdown formatters, HTTP Basic middleware (401/200), and JSONL usage logging. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
"""HTTP client unit tests (no live network)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Make the package importable when pytest runs from repo root
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
|
||||
def _reload_client(monkeypatch, key="abc", url="http://test.invalid"):
|
||||
monkeypatch.setenv("AG_BIDS_API_URL", url)
|
||||
monkeypatch.setenv("AG_BIDS_API_KEY", key)
|
||||
from ag_bids_mcp import client
|
||||
importlib.reload(client)
|
||||
return client
|
||||
|
||||
|
||||
def test_missing_api_key_raises(monkeypatch):
|
||||
monkeypatch.setenv("AG_BIDS_API_URL", "http://test.invalid")
|
||||
monkeypatch.delenv("AG_BIDS_API_KEY", raising=False)
|
||||
from ag_bids_mcp import client
|
||||
importlib.reload(client)
|
||||
import pytest
|
||||
with pytest.raises(client.AgBidsError):
|
||||
client.latest()
|
||||
|
||||
|
||||
def test_get_sends_api_key_header(monkeypatch):
|
||||
client = _reload_client(monkeypatch, key="topsecret")
|
||||
captured = {}
|
||||
|
||||
class FakeResp:
|
||||
status_code = 200
|
||||
text = ""
|
||||
def json(self):
|
||||
return {"count": 0, "rows": []}
|
||||
|
||||
def fake_get(url, params=None, timeout=None, headers=None):
|
||||
captured["url"] = url
|
||||
captured["params"] = dict(params or {})
|
||||
captured["headers"] = dict(headers or {})
|
||||
return FakeResp()
|
||||
|
||||
monkeypatch.setattr(client.httpx, "get", fake_get)
|
||||
out = client.latest(commodity="corn")
|
||||
assert captured["headers"]["X-API-Key"] == "topsecret"
|
||||
assert captured["url"].endswith("/api/data/latest")
|
||||
assert captured["params"] == {"commodity": "corn"}
|
||||
assert out == {"count": 0, "rows": []}
|
||||
|
||||
|
||||
def test_get_drops_none_params(monkeypatch):
|
||||
client = _reload_client(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
class FakeResp:
|
||||
status_code = 200
|
||||
text = ""
|
||||
def json(self): return {"count": 0, "rows": []}
|
||||
|
||||
def fake_get(url, params=None, timeout=None, headers=None):
|
||||
captured["params"] = dict(params or {})
|
||||
return FakeResp()
|
||||
|
||||
monkeypatch.setattr(client.httpx, "get", fake_get)
|
||||
client.latest(commodity="corn", source=None, delivery="", kind=None)
|
||||
# None and "" should both be dropped
|
||||
assert captured["params"] == {"commodity": "corn"}
|
||||
|
||||
|
||||
def test_get_raises_on_non_200(monkeypatch):
|
||||
client = _reload_client(monkeypatch)
|
||||
|
||||
class FakeResp:
|
||||
status_code = 401
|
||||
text = "no key"
|
||||
def json(self): return {}
|
||||
|
||||
monkeypatch.setattr(client.httpx, "get", lambda *a, **k: FakeResp())
|
||||
import pytest
|
||||
with pytest.raises(client.AgBidsError):
|
||||
client.best("corn")
|
||||
|
||||
|
||||
def test_get_raises_on_network_error(monkeypatch):
|
||||
client = _reload_client(monkeypatch)
|
||||
|
||||
def boom(*a, **k):
|
||||
raise client.httpx.ConnectError("network is unreachable")
|
||||
|
||||
monkeypatch.setattr(client.httpx, "get", boom)
|
||||
import pytest
|
||||
with pytest.raises(client.AgBidsError):
|
||||
client.sources()
|
||||
|
||||
|
||||
def test_each_endpoint_hits_expected_path(monkeypatch):
|
||||
client = _reload_client(monkeypatch)
|
||||
calls = []
|
||||
|
||||
class FakeResp:
|
||||
status_code = 200
|
||||
text = ""
|
||||
def json(self): return {}
|
||||
|
||||
def fake_get(url, params=None, timeout=None, headers=None):
|
||||
calls.append((url, dict(params or {})))
|
||||
return FakeResp()
|
||||
|
||||
monkeypatch.setattr(client.httpx, "get", fake_get)
|
||||
client.latest()
|
||||
client.history("corn", days=7)
|
||||
client.best("soy")
|
||||
client.inputs(product="lime")
|
||||
client.sources()
|
||||
client.deliveries("corn")
|
||||
client.todays_summary()
|
||||
|
||||
paths = [u.replace("http://test.invalid", "") for u, _ in calls]
|
||||
assert paths == [
|
||||
"/api/data/latest",
|
||||
"/api/data/history",
|
||||
"/api/data/best",
|
||||
"/api/data/inputs",
|
||||
"/api/data/sources",
|
||||
"/api/data/deliveries",
|
||||
"/api/brief/snapshot",
|
||||
]
|
||||
# history call sent commodity + days
|
||||
assert calls[1][1] == {"commodity": "corn", "days": 7}
|
||||
# best call sent only commodity
|
||||
assert calls[2][1] == {"commodity": "soy"}
|
||||
# todays_summary uses kind=morning
|
||||
assert calls[6][1] == {"kind": "morning"}
|
||||
Reference in New Issue
Block a user