Giving an MCP agent a way to refresh its own data
Shipped
My local-fitness agent talks to a SQLite database of my Garmin data over the Model Context Protocol. Every tool it had was a reader: query workouts, pull a metric trend, grade the training plan. If the data was stale, the agent had no move. The only way to freshen it was the CLI (fitness pull) or the old web UI, neither of which an MCP client like opencode or Claude Desktop can reach.
v0.16.0 adds one tool, sync_garmin_data, that lets the agent pull the latest data on request. That sounds small, and the diff is small, but it crosses a real line: it is the first tool that writes. The interesting part is everything I did to make that write safe to expose.
The problem with a read-only agent
An MCP server exposes tools that a model can discover and call on its own. The protocol is explicitly model-controlled: the client lists your tools, the model picks one based on the conversation, and it calls it without you in the loop for that decision. That is exactly what you want for reads. It is also why a write tool deserves more care than a read tool.
Before this release, if I asked the agent “how did I sleep last night” and last night’s data had not synced yet, the honest answer was “I do not know, and I cannot find out.” The agent was a very well-read librarian locked out of the delivery dock.
Build it: the sync tool
The whole tool is a thin wrapper over the same gap-aware pull the rest of the app uses. Here it is, defined with the Agent SDK’s @tool decorator:
@tool(
"sync_garmin_data",
"Pull the latest data from Garmin Connect into the database (gap-aware: "
"fills missing days, always refreshes the last few days so day-end totals "
"overwrite partial values) and recompute baselines/training-load if new "
"data landed. Every other tool only reads what's already in the DB — call "
"this first when the user asks to sync/refresh/pull/update their data, or "
"when today's data looks stale or missing.",
{},
)
async def sync_garmin_data(_args: dict) -> dict:
result = await asyncio.to_thread(daily_ingest.pull, max_days=SYNC_MAX_DAYS)
if result.get("status") == "success" and result.get("days_pulled", 0) > 0:
await asyncio.to_thread(baselines_mod.recompute, lookback_days=90)
if result.get("error"):
return _err(
result["error"],
status=result.get("status"),
days_pulled=result.get("days_pulled", 0),
last_date=result.get("last_date"),
)
return _text(result)
Three design choices are doing the work here, and none of them are obvious from the size of the function.
The description is half the tool. With a model-controlled protocol, the description is not documentation, it is the routing logic. The model reads it to decide when to call. So I spell out the trigger words (“sync/refresh/pull/update”) and the negative space (“every other tool only reads”). A vague description like “syncs data” would leave the model guessing whether a stale-looking answer warrants a pull. The MCP spec leans on this: a tool is just a name, a description, and an input schema, and the description is the only place behavior gets communicated to the model.
The pull is gap-aware and bounded, not a full backfill. daily_ingest.pull fills only the missing days and re-fetches the last few (so a day-end total overwrites a partial mid-day value), and max_days=SYNC_MAX_DAYS caps it. This matters because the model can call this tool whenever it wants. If a single call could trigger a multi-minute backfill of a year’s absence, one over-eager sync would stall the whole conversation. A bounded, incremental pull keeps every call cheap and roughly idempotent: calling it twice in a row when you are already current does almost nothing the second time.
The offload is deliberate. asyncio.to_thread moves the blocking Garmin HTTP call off the event loop, so the server stays responsive to other requests while a sync runs.
Use it: keep the write out of read-only loops
Adding a write tool to the registry is not the whole job. My daily-brief generation runs the agent against a read-only allow-list of tools, on purpose, so brief generation can never have a side effect. A brief is a report; it should describe the data, not change it.
So sync_garmin_data goes into the general tool set but is deliberately excluded from the brief loop’s allow-list:
# General MCP surface — an interactive agent can sync on request.
ALL_TOOLS = [
query_workouts,
get_metric_trend,
training_load_status,
sync_garmin_data, # the new write tool
# ...
]
# Brief generation runs against a read-only subset — no side effects, ever.
BRIEF_TOOLS = [
query_workouts,
get_metric_trend,
training_load_status,
# sync_garmin_data intentionally absent
]
That split is the real safety mechanism. The MCP tool-annotations work formalizes the same instinct with hints like readOnlyHint and idempotentHint that tell a client how a tool behaves. Their default is worth internalizing: “a tool with no annotations is assumed to be non-read-only, potentially destructive, non-idempotent, and open-world.” In other words, the protocol assumes the worst about any tool you do not describe, and it expects you to carve out which contexts a mutating tool is allowed to run in. My allow-list is that carve-out in code rather than metadata.
Verify it, and the failure mode to watch
The test that earns its keep is the one that would fail if the write leaked into the brief path:
def test_sync_tool_excluded_from_brief_loop():
brief_tool_names = {t.name for t in BRIEF_TOOLS}
assert "sync_garmin_data" not in brief_tool_names
# and it IS available to the general agent
assert "sync_garmin_data" in {t.name for t in ALL_TOOLS}
That is a real assertion about a real boundary. If someone later “tidies up” by building BRIEF_TOOLS from ALL_TOOLS, this test goes red before a brief can ever trigger a Garmin pull.
The failure mode I care about most is a partial-day overwrite. Garmin reports running totals during the day, so a sync at noon writes a half-day step count. If the pull only ever filled missing days, that noon value would stick forever. That is why the tool always re-fetches the trailing few days instead of trusting that a day it already has is final. The MCP tools spec puts input validation and safe outputs on the server’s shoulders, and “always refresh the last few days” is that responsibility showing up in the data layer.
One tool, one allow-list decision, one regression test. The agent can now keep itself current, and the one loop that must stay pure still cannot touch the network.
Sources
- MCP: Tools — the tool model (name, description, input schema), model-controlled invocation, and the server’s validation responsibilities.
- Tool Annotations as Risk Vocabulary —
readOnlyHint/idempotentHint/destructiveHintand the cautious default for un-annotated tools. - MCP Tool Annotations Explained — why treating a mutating tool differently from a reader is a security posture, not a nicety.
Changelog
- New MCP tool
sync_garmin_data: gap-aware Garmin pull plus baseline recompute, excluded from the read-only brief loop (d6c2c0c)