The tools in the earlier articles computed their answers locally. Most useful
tools instead reach out to an external service: a weather API, a payments
provider, an internal microservice. That introduces three problems a naive
httpx.get ignores: where the API key lives, what happens when the call is
slow, and how a failure reaches the user. This article builds a
FastMCP tool that calls a real HTTP API and handles all
three, the macOS way.
The tool wraps Open-Meteo, a free weather API that needs
no key, so the whole thing runs end to end without signup. The important parts
generalize to any service: a secret loaded from an environment variable or the
macOS Keychain, a bounded timeout, and every network or HTTP failure
turned into a ToolError the MCP client can display. The stack is Mac-native:
uv, make, and the security command.
What you will build
keychain.py: load a secret from an env var, falling back to the macOS Keychain.weather.py: an async Open-Meteo client with a timeout and failure mapping, built to be tested without a network.- A
get_weathertool and a server that exposes it. - A
pytestsuite that drives success and every failure path through a mock HTTP transport, plusmaketargets to store a key in the Keychain and run the tool.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. Thesecuritycommand used for the Keychain ships with macOS. - Basic familiarity with
async/awaitand HTTP.
Everything runs through uv and make. The tool makes a real network call, so
make smoke needs internet access.
Step 1: Scaffold and lock down hygiene
Create the workspace and a .gitignore first. It ignores .env, which may hold
an API key during development.
Create the files
mkdir -p external-api-fastmcp-server-macos
cd external-api-fastmcp-server-macos
touch .gitignore
Add the code: .gitignore
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
logs/
*.log
*.pid
.env
.DS_Store
Detailed breakdown
.envis ignored so a key you export for local testing never lands in git. The Keychain (Step 3) is the better home for it, but ignoring.envcovers the quick path too.
Step 2: Initialize the project with uv
FastMCP depends on httpx, so the HTTP client is already available.
Create the files
uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
[project]
name = "macmcp"
version = "0.1.0"
description = "A FastMCP tool that calls an external API"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.3",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
httpxcomes with FastMCP, so there is no separate HTTP dependency, and the samehttpx.MockTransportthe tests use is available.
Step 3: Load the secret from the Keychain
Never hard-code an API key or read it from a committed file. Prefer an environment variable (convenient for CI and containers), and fall back to the macOS Keychain so a developer’s key stays out of shell history and dotfiles.
Create the file
touch keychain.py
Add the code: keychain.py
"""Load a secret from an environment variable or the macOS Keychain.
Precedence: an environment variable wins (handy for CI and containers); if it is
unset, fall back to the login Keychain via the `security` CLI. Storing an API key
in the Keychain keeps it out of your shell history, dotfiles, and the repo.
Store a key once with:
security add-generic-password -s macmcp-weather -a "$USER" -w YOUR_KEY -U
"""
import os
import subprocess
def load_secret(env_var: str, keychain_service: str, account: str | None = None) -> str | None:
"""Return the secret from the env var, else the Keychain, else None."""
value = os.environ.get(env_var)
if value:
return value
return _keychain_lookup(keychain_service, account)
def _keychain_lookup(service: str, account: str | None) -> str | None:
cmd = ["security", "find-generic-password", "-s", service]
if account:
cmd += ["-a", account]
cmd += ["-w"] # print only the password
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
except (OSError, subprocess.SubprocessError):
return None
if result.returncode != 0:
return None
return result.stdout.strip() or None
Detailed breakdown
load_secretchecks the environment first. An env var is the easiest way to inject a secret in CI or a container, so it wins when set._keychain_lookupshells out tosecurity find-generic-password -w, which prints just the stored password. A missing item exits non-zero, and thetryguards the case where the command is absent (non-macOS), so both returnNonerather than raising.- The module is named
keychain.py, notsecrets.py, on purpose: a top-levelsecrets.pywould shadow Python’s standard-librarysecretsmodule.
Step 4: The external-API client
Wrap the API in one async function. Two design choices make it reliable and
testable: a bounded timeout, and an injectable client so tests can supply a
mock transport instead of hitting the network. Every failure becomes a
ToolError.
Create the file
touch weather.py
Add the code: weather.py
"""Call the Open-Meteo weather API, with a timeout and clean error mapping.
Open-Meteo is free and needs no key, which makes it a good stand-in for any
external service. The functions here show the pattern that matters: a bounded
timeout, an injectable client for tests, and every network/HTTP failure turned
into a ToolError the MCP client can display.
"""
import httpx
from fastmcp.exceptions import ToolError
from keychain import load_secret
API_URL = "https://api.open-meteo.com/v1/forecast"
DEFAULT_TIMEOUT = 5.0
def api_headers() -> dict[str, str]:
"""Attach an API key if one is configured (Open-Meteo ignores it).
Many real APIs require a key; this shows where it goes. The key is read from
the WEATHER_API_KEY env var or the `macmcp-weather` Keychain item.
"""
key = load_secret("WEATHER_API_KEY", "macmcp-weather")
return {"X-API-Key": key} if key else {}
async def fetch_current_weather(
latitude: float,
longitude: float,
*,
client: httpx.AsyncClient | None = None,
timeout: float = DEFAULT_TIMEOUT,
) -> dict:
"""Return current temperature and wind for a location, or raise ToolError."""
params = {
"latitude": latitude,
"longitude": longitude,
"current": "temperature_2m,wind_speed_10m",
}
owns_client = client is None
if owns_client:
client = httpx.AsyncClient(timeout=timeout, headers=api_headers())
try:
response = await client.get(API_URL, params=params)
response.raise_for_status()
current = response.json().get("current", {})
except httpx.TimeoutException as exc:
raise ToolError(f"weather service timed out after {timeout}s") from exc
except httpx.HTTPStatusError as exc:
raise ToolError(
f"weather service returned HTTP {exc.response.status_code}"
) from exc
except httpx.RequestError as exc:
raise ToolError("could not reach the weather service") from exc
finally:
if owns_client:
await client.aclose()
return {
"temperature_c": current.get("temperature_2m"),
"wind_kmh": current.get("wind_speed_10m"),
}
Detailed breakdown
timeout=DEFAULT_TIMEOUTbounds the request. Without it, a hung upstream would hang the tool call indefinitely; with it, a slow service fails fast and predictably.- The
clientparameter is the testing seam. In production the function creates and closes its ownhttpx.AsyncClient; in tests you pass one built on aMockTransport, so no network is touched.owns_clientensures the function only closes a client it created. - The
exceptladder maps failures toToolError. A timeout, an HTTP 4xx/5xx (raise_for_statusturns those intoHTTPStatusError), and a connection error (RequestError) each become a specific, human-readableToolError. The client sees “weather service timed out” instead of a Python traceback, and the raw exception is chained withfrom excfor server-side logs. api_headersshows where a key goes. Open-Meteo ignores it, but for a keyed API the same call attachesX-API-Keyfrom the Keychain or env.
Step 5: The server
The tool is a thin async wrapper over the client function.
Create the file
touch server.py
Add the code: server.py
"""A FastMCP server whose tool calls an external HTTP API.
The tool wraps the Open-Meteo weather API. Secrets load from an env var or the
macOS Keychain, the request has a bounded timeout, and any failure becomes a
ToolError so the client gets a clear message instead of a stack trace.
"""
import os
from fastmcp import FastMCP
from weather import fetch_current_weather
mcp = FastMCP("macmcp")
@mcp.tool
async def get_weather(latitude: float, longitude: float) -> dict:
"""Current temperature (C) and wind (km/h) for a latitude/longitude."""
return await fetch_current_weather(latitude, longitude)
def main() -> None:
if os.environ.get("MCP_TRANSPORT", "stdio").lower() == "http":
mcp.run(
transport="http",
host=os.environ.get("MCP_HOST", "127.0.0.1"),
port=int(os.environ.get("MCP_PORT", "8000")),
)
else:
mcp.run() # stdio
if __name__ == "__main__":
main()
Detailed breakdown
get_weatherisasync. FastMCP awaits async tools directly, so the tool can await the HTTP call without blocking the event loop. The typed parameters and returndictbecome the tool’s schema.- All the hard parts (secrets, timeout, error mapping) live in
weather.py, so the tool stays a one-line delegation. That separation is what makes the behavior testable.
Step 6: Test success and every failure, with no network
The failure paths are the point of the article, so test them directly.
httpx.MockTransport lets each test decide what the “server” returns or how it
fails, so the suite is fast, hermetic, and covers cases a live API would rarely
produce on demand.
Create the files
mkdir -p tests
touch tests/__init__.py tests/test_weather.py tests/test_keychain.py pytest.ini
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
Add the code: tests/test_weather.py
"""Test the external-API call with a mock transport: success and every failure.
httpx.MockTransport lets each test decide exactly what the "server" returns (or
how it fails), so the error-mapping paths are exercised with no network.
"""
import httpx
import pytest
from fastmcp.exceptions import ToolError
from weather import fetch_current_weather
def _client(handler) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
async def test_success_returns_parsed_fields():
def handler(request):
return httpx.Response(200, json={"current": {"temperature_2m": 14.8, "wind_speed_10m": 17.7}})
async with _client(handler) as client:
result = await fetch_current_weather(37.77, -122.42, client=client)
assert result == {"temperature_c": 14.8, "wind_kmh": 17.7}
async def test_http_error_becomes_toolerror():
def handler(request):
return httpx.Response(503, text="upstream down")
async with _client(handler) as client:
with pytest.raises(ToolError, match="HTTP 503"):
await fetch_current_weather(0, 0, client=client)
async def test_timeout_becomes_toolerror():
def handler(request):
raise httpx.ReadTimeout("timed out", request=request)
async with _client(handler) as client:
with pytest.raises(ToolError, match="timed out"):
await fetch_current_weather(0, 0, client=client)
async def test_connection_error_becomes_toolerror():
def handler(request):
raise httpx.ConnectError("no route", request=request)
async with _client(handler) as client:
with pytest.raises(ToolError, match="could not reach"):
await fetch_current_weather(0, 0, client=client)
Add the code: tests/test_keychain.py
"""Test secret resolution: env var wins, else Keychain, else None."""
import keychain
from weather import api_headers
def test_env_var_takes_precedence(monkeypatch):
monkeypatch.setenv("WEATHER_API_KEY", "from-env")
# Even if the Keychain had a value, the env var wins.
monkeypatch.setattr(keychain, "_keychain_lookup", lambda *a: "from-keychain")
assert keychain.load_secret("WEATHER_API_KEY", "macmcp-weather") == "from-env"
def test_falls_back_to_keychain(monkeypatch):
monkeypatch.delenv("WEATHER_API_KEY", raising=False)
monkeypatch.setattr(keychain, "_keychain_lookup", lambda *a: "from-keychain")
assert keychain.load_secret("WEATHER_API_KEY", "macmcp-weather") == "from-keychain"
def test_returns_none_when_unset(monkeypatch):
monkeypatch.delenv("WEATHER_API_KEY", raising=False)
monkeypatch.setattr(keychain, "_keychain_lookup", lambda *a: None)
assert keychain.load_secret("WEATHER_API_KEY", "macmcp-weather") is None
def test_api_headers_include_key_when_present(monkeypatch):
monkeypatch.setenv("WEATHER_API_KEY", "abc123")
assert api_headers() == {"X-API-Key": "abc123"}
def test_api_headers_empty_when_absent(monkeypatch):
monkeypatch.delenv("WEATHER_API_KEY", raising=False)
monkeypatch.setattr(keychain, "_keychain_lookup", lambda *a: None)
assert api_headers() == {}
Detailed breakdown
test_weather.pycovers the whole ladder: a200returns the parsed fields; a503, aReadTimeout, and aConnectErroreach raise aToolErrorwith the expected message. The mock transport reproduces failures a live API would not give you reliably.test_keychain.pyverifies the precedence (env over Keychain over nothing) by monkeypatching_keychain_lookup, so the tests never touch the real Keychain, and confirms the header is attached only when a key exists.
Run them:
uv run pytest -v
All tests pass, with no network and no Keychain access.
Step 7: The Makefile
Wrap development, the Keychain helpers, and the launchd deployment. Plain make
prints help.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
# --- Deploy configuration (launchd user agent) --------------------------------
LABEL := com.macmcp.weather
PLIST := $(HOME)/Library/LaunchAgents/$(LABEL).plist
UV := $(shell command -v uv)
DIR := $(shell pwd)
DOMAIN := gui/$(shell id -u)
# Keychain service the server reads its API key from
KEYCHAIN_SERVICE := macmcp-weather
.PHONY: help install serve test smoke set-key del-key deploy undeploy status logs clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
install: ## Sync runtime and dev dependencies
uv sync
serve: ## Run the HTTP server in the foreground (Ctrl-C to stop)
MCP_TRANSPORT=http uv run python server.py
test: ## Run the test suite
uv run pytest -v
smoke: ## Call get_weather against a running server (LAT/LON overridable)
uv run python scripts/smoke.py
set-key: ## Store an API key in the Keychain: make set-key VALUE=your_key
@test -n "$(VALUE)" || (echo 'Usage: make set-key VALUE=your_key' && exit 1)
@security add-generic-password -s $(KEYCHAIN_SERVICE) -a "$(USER)" -w "$(VALUE)" -U \
&& echo "Stored key in Keychain service '$(KEYCHAIN_SERVICE)'."
del-key: ## Remove the API key from the Keychain
@security delete-generic-password -s $(KEYCHAIN_SERVICE) -a "$(USER)" >/dev/null 2>&1 \
&& echo "Removed key." || echo "No key to remove."
deploy: ## Render the launchd plist and start the service
@mkdir -p logs
@sed -e 's#@UV@#$(UV)#g' \
-e 's#@DIR@#$(DIR)#g' \
-e 's#@PATH@#$(dir $(UV)):/usr/bin:/bin#g' \
deploy/$(LABEL).plist.template > $(PLIST)
launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
launchctl bootstrap $(DOMAIN) $(PLIST)
@echo "Deployed $(LABEL). Check: make status"
undeploy: ## Stop the service and remove the launchd plist
launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
rm -f $(PLIST)
@echo "Removed $(LABEL)."
status: ## Show whether the service is registered and running
@launchctl print $(DOMAIN)/$(LABEL) 2>/dev/null \
| grep -E 'state =|pid =' || echo "$(LABEL) is not loaded."
logs: ## Tail the service log files
@tail -n 40 -f logs/server.out.log logs/server.err.log
clean: ## Remove caches and logs
rm -rf .pytest_cache logs __pycache__ tests/__pycache__
Detailed breakdown
set-key/del-keywrap thesecuritycommand so a developer stores or removes the API key with one target. The key lands in the login Keychain under themacmcp-weatherservice, exactly whereload_secretlooks for it.deployruns the server as alaunchdservice, matching the companion deploy article. The service inherits no shell environment, so a deployed server should read its key from the Keychain (or an env entry in the plist), not from a.envyou sourced interactively.
Step 8: Run it
Add the launchd plist and the smoke client, then call the tool.
Create the files
mkdir -p deploy scripts
touch deploy/com.macmcp.weather.plist.template scripts/smoke.py
Add the code: deploy/com.macmcp.weather.plist.template
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.macmcp.weather</string>
<key>ProgramArguments</key>
<array>
<string>@UV@</string>
<string>run</string>
<string>python</string>
<string>server.py</string>
</array>
<key>WorkingDirectory</key>
<string>@DIR@</string>
<key>EnvironmentVariables</key>
<dict>
<key>MCP_TRANSPORT</key>
<string>http</string>
<key>MCP_HOST</key>
<string>127.0.0.1</string>
<key>MCP_PORT</key>
<string>8000</string>
<key>PATH</key>
<string>@PATH@</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>@DIR@/logs/server.out.log</string>
<key>StandardErrorPath</key>
<string>@DIR@/logs/server.err.log</string>
</dict>
</plist>
Add the code: scripts/smoke.py
"""Call the get_weather tool over HTTP against a running server."""
import asyncio
import os
from fastmcp import Client
URL = os.environ.get("MCP_URL", "http://127.0.0.1:8000/mcp/")
# Default coordinates: San Francisco.
LAT = float(os.environ.get("LAT", "37.7749"))
LON = float(os.environ.get("LON", "-122.4194"))
async def main() -> None:
async with Client(URL) as client:
result = await client.call_tool("get_weather", {"latitude": LAT, "longitude": LON})
print(f"weather at ({LAT}, {LON}):", result.data)
if __name__ == "__main__":
asyncio.run(main())
Detailed breakdown
smoke.pycallsget_weatherover HTTP for a location, overridable withLAT/LON. It makes a real Open-Meteo request, so it confirms the whole path: MCP request, external call, and parsed response.
Store a key (optional, since Open-Meteo needs none), deploy, and call the tool:
make set-key VALUE=demo-key # optional: goes into the Keychain
make deploy
make status # state = running, a live pid
make smoke
# weather at (37.7749, -122.4194): {'temperature_c': 14.8, 'wind_kmh': 17.7}
make undeploy
Point it elsewhere with coordinates:
LAT=51.5074 LON=-0.1278 make smoke # London
Troubleshooting
ToolError: could not reach the weather service. No network, or the API is down. Confirm withcurl 'https://api.open-meteo.com/v1/forecast?latitude=0&longitude=0¤t=temperature_2m'.ToolError: weather service timed out. The request exceeded the timeout. That is the timeout working; raiseDEFAULT_TIMEOUTif the API is legitimately slow, but keep it bounded.- The deployed server cannot find the key.
launchdgives the process no shell environment. Store the key in the Keychain (make set-key), whichload_secretreads, or add it to the plist’sEnvironmentVariables. - A Keychain access prompt appears. The first time a new binary reads a
Keychain item, macOS may ask for permission. Choose “Always Allow” for the
interpreter, or store the key with
-Uand read it from the same login session.
Recap
You built a FastMCP tool that calls an external API safely:
- Secrets load from an env var or the macOS Keychain, never from code or a committed file.
- The request has a bounded timeout, so a slow upstream fails fast.
- Every network and HTTP failure maps to a
ToolError, so the client sees a clear message, not a traceback. - An injectable client makes the success and failure paths testable with a mock
transport, and
uv,make, andlaunchdrun and deploy it.
Next improvements
- Add a retry with backoff for transient
5xx/timeout errors (for example withtenacity). - Cache responses briefly to cut repeated calls (see the stateful-SQLite article for a store, or an in-memory TTL cache).
- Reuse one
httpx.AsyncClientacross calls via a lifespan, instead of creating one per request, when call volume is high. - Return richer errors (rate-limit vs auth vs outage) by inspecting the upstream status and body.