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_weather tool and a server that exposes it.
  • A pytest suite that drives success and every failure path through a mock HTTP transport, plus make targets to store a key in the Keychain and run the tool.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make. The security command used for the Keychain ships with macOS.
  • Basic familiarity with async/await and 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

  • .env is 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 .env covers 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

  • httpx comes with FastMCP, so there is no separate HTTP dependency, and the same httpx.MockTransport the 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_secret checks 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_lookup shells out to security find-generic-password -w, which prints just the stored password. A missing item exits non-zero, and the try guards the case where the command is absent (non-macOS), so both return None rather than raising.
  • The module is named keychain.py, not secrets.py, on purpose: a top-level secrets.py would shadow Python’s standard-library secrets module.

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_TIMEOUT bounds the request. Without it, a hung upstream would hang the tool call indefinitely; with it, a slow service fails fast and predictably.
  • The client parameter is the testing seam. In production the function creates and closes its own httpx.AsyncClient; in tests you pass one built on a MockTransport, so no network is touched. owns_client ensures the function only closes a client it created.
  • The except ladder maps failures to ToolError. A timeout, an HTTP 4xx/5xx (raise_for_status turns those into HTTPStatusError), and a connection error (RequestError) each become a specific, human-readable ToolError. The client sees “weather service timed out” instead of a Python traceback, and the raw exception is chained with from exc for server-side logs.
  • api_headers shows where a key goes. Open-Meteo ignores it, but for a keyed API the same call attaches X-API-Key from 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_weather is async. FastMCP awaits async tools directly, so the tool can await the HTTP call without blocking the event loop. The typed parameters and return dict become 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.py covers the whole ladder: a 200 returns the parsed fields; a 503, a ReadTimeout, and a ConnectError each raise a ToolError with the expected message. The mock transport reproduces failures a live API would not give you reliably.
  • test_keychain.py verifies 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-key wrap the security command so a developer stores or removes the API key with one target. The key lands in the login Keychain under the macmcp-weather service, exactly where load_secret looks for it.
  • deploy runs the server as a launchd service, 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 .env you 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.py calls get_weather over HTTP for a location, overridable with LAT/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 with curl 'https://api.open-meteo.com/v1/forecast?latitude=0&longitude=0&current=temperature_2m'.
  • ToolError: weather service timed out. The request exceeded the timeout. That is the timeout working; raise DEFAULT_TIMEOUT if the API is legitimately slow, but keep it bounded.
  • The deployed server cannot find the key. launchd gives the process no shell environment. Store the key in the Keychain (make set-key), which load_secret reads, or add it to the plist’s EnvironmentVariables.
  • 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 -U and 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, and launchd run and deploy it.

Next improvements

  • Add a retry with backoff for transient 5xx/timeout errors (for example with tenacity).
  • Cache responses briefly to cut repeated calls (see the stateful-SQLite article for a store, or an in-memory TTL cache).
  • Reuse one httpx.AsyncClient across 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.