Skip to main content

Tutorial: weather tools over MCP

By the end of this tutorial you will have published a tool as a hosted MCP server, proved it works by calling it directly, and then wired an agent to it so the model calls the tool on its own. You will also see the tool's second immutable version and roll back to the first.

MCP (Model Context Protocol) is an open standard for letting an AI model call your code. A tool is a function with a name, a description, and typed inputs. An MCP server lists its tools and runs them when asked. An MCP client — an agent, a chat app, a coding assistant — is whatever calls it. The conversation is plain HTTP carrying JSON.

Why bother, when an agent can just define a Python function inline? Three reasons, and they all show up in this tutorial:

  • Reuse. One published tool serves every agent in the project, versioned on its own instead of copy-pasted into each agent.
  • Credentials. An MCP tool can read project secrets at call time. Tool code running inside an agent deliberately cannot.
  • Rollback. Every publish is an immutable, digest-pinned version you can return to with one call.

Budget about 30 minutes.

What you are building

Before you begin

You need:

  • A platform account with the admin role on a project. Creating servers and publishing tools are admin actions. Ask your administrator for an account or an invitation link — see Create an account.
  • platformctl, signed in — see Install the CLI.
  • curl and jq.

There is no public API hostname in this alpha, so point CAI_API at the endpoint your administrator gives you, or at an address platformctl port-forwards for you:

export CAI_API="http://localhost:8080"
export TOK="$CAI_TOKEN" # cached by 'platformctl login'
export PROJ="00000000-0000-0000-0000-000000000000" # your project UUID
export SHORT="ab12cd" # your project's short id

If $CAI_TOKEN is empty (platformctl login caches its token in a file, not in your shell), get one directly:

export TOK=$(curl -s "$CAI_API/v1/auth/login" \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"your-password"}' | jq -r .token)

Session tokens last 12 hours. Both project values come from one command:

platformctl projects list

You should see:

SLUG NAME SHORT ROLE ID
ml-team ML Team ab12cd admin 0f7a5c21-...-uuid

platformctl has no MCP commands in this alpha — everything below is the API or the console (Compute → MCP servers).


Act 1: create the server

A server starts empty. It is a named container that tools get published into.

curl -s -X POST "$CAI_API/v1/projects/$PROJ/mcpservers" \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"name":"weather-tools","expose":""}'

You should see:

{"mcp_server":{"name":"weather-tools","expose":"","status":"pending","tool_names":[],"tool_count":0,"created_at":"...","visibility":"..."}}

The two fields that matter:

  • name — a lowercase DNS label, at most 40 characters: letters, digits and hyphens, starting with a letter. It is set once and never changes.
  • expose"" means cluster-local (reachable only from inside the platform network), "apps" publishes it on a public HTTPS address. Either way the endpoint requires a bearer token; exposure only changes who can reach the door, not whether it is locked.

status is pending because a server with no tools has nothing to build yet.

Only two fields are read

The create endpoint reads name and expose and silently ignores everything else — including scaling fields you may see in older examples or in the console's create dialog. The body is capped at 4 KiB.

Real errors you might hit:

  • 400missing or invalid 'name' (must be a lowercase DNS label, <=40 chars)
  • 400'expose' must be "" (cluster-local) or "apps"
  • 409an mcp server named weather-tools already exists in this project

At this point you have: an empty server named weather-tools, status pending.


Act 2: write the tool

A tool is a plain Python function with a decorator. You do not write server code, a JSON schema, or authentication — the platform infers the schema from your signature and the description from your docstring.

cat > get_forecast.py <<'PYEOF'
import crusoe_mcp as crusoe

_ON_FILE = {
"reykjavik": {"summary": "overcast with sleet", "high_c": 4, "low_c": -1},
"san francisco": {"summary": "morning fog, clearing by noon", "high_c": 18, "low_c": 11},
"singapore": {"summary": "thunderstorms in the afternoon", "high_c": 31, "low_c": 26},
}


@crusoe.tool()
def get_forecast(city: str) -> dict:
"""Tomorrow's weather forecast for a city.

Args:
city: City name, for example "Reykjavik".
"""
reading = _ON_FILE.get(city.strip().lower())
if reading is None:
return {"city": city, "known": False,
"note": "No forecast on file for this city."}
result = {"city": city, "known": True}
result.update(reading)
return result
PYEOF

This version answers from a small table so the tutorial runs with no external weather account. Act 10 shows the one-line change that turns it into a real API call with a real key.

The tool name in the URL becomes the module file (tools/get_forecast.py), so it must be a lowercase identifier — letters, digits and underscores, not starting with an underscore, at most 63 characters.

At this point you have: a tool on disk. Nothing is published.


Act 3: publish it

Publishing is a PUT to the tool's path. jq handles the awkward job of embedding a Python file inside JSON:

jq -n --rawfile handler get_forecast.py \
'{handler: $handler, description: "Tomorrow'"'"'s weather forecast for a city"}' \
| curl -s -X PUT "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools/tools/get_forecast" \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d @-

You should see (HTTP 202):

{"server":"weather-tools","name":"get_forecast","published":true,"build_id":"9c31a7e5-...-uuid","note":"building a new immutable version from the server's current tool set"}

That 202 Accepted means the platform snapshotted the server's entire tool set and started building version 1 in the background. Publishing is always a whole-server snapshot, never a patch to one file — which is what makes a version something you can trust.

Poll until it is serving:

curl -s "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools" \
-H "Authorization: Bearer $TOK" | jq '.mcp_server | {status, version, url, tool_names, message}'

You should see, after a minute or two:

{
"status": "ready",
"version": 1,
"url": "http://weather-tools.cai-p-ab12cd.svc.cluster.local",
"tool_names": ["get_forecast"],
"message": ""
}

The lifecycle is pendingbuildingdeployingready, or failed. There is no separate build-log endpoint: when a build fails, the reason lands in the server's message field, and the console renders it in full.

At this point you have: version 1 of weather-tools, live at an endpoint.


Act 4: inspect what you published

Two read-only checks, both available to any project member:

curl -s "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools/tools" \
-H "Authorization: Bearer $TOK" | jq '.tools[] | {name, description, credential_keys}'

You should see:

{
"name": "get_forecast",
"description": "Tomorrow's weather forecast for a city",
"credential_keys": []
}

And the version history:

curl -s "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools/versions" \
-H "Authorization: Bearer $TOK" | jq '.versions[] | {version, current, tool_names, created_at}'

You should see:

{
"version": 1,
"current": true,
"tool_names": ["get_forecast"],
"created_at": "..."
}

At this point you have: confirmation that the platform read your signature and docstring correctly, and one recorded version.


Act 5: get the endpoint and the bearer token

Two values are needed to call the server.

The endpoint is the server's url with /mcp appended:

export MCP_URL="$(curl -s "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools" \
-H "Authorization: Bearer $TOK" | jq -r '.mcp_server.url')/mcp"
echo "$MCP_URL"

You should see:

http://weather-tools.cai-p-ab12cd.svc.cluster.local/mcp

The bearer token is minted by the platform, one per server, in the format cai_mcp_ followed by 64 hexadecimal characters. It is injected into the server so the server can validate callers, and it is deliberately not returned by any API — the console shows it as a placeholder, never a value.

Getting the token is an alpha rough edge

There is no endpoint that reveals the per-server bearer. It lives in a Kubernetes Secret in your project's namespace. If you have cluster access:

kubectl -n "cai-p-$SHORT" get secret mcp-weather-tools-auth \
-o jsonpath='{.data.CAI_MCP_AUTH_TOKEN}' | base64 -d

If you do not, ask your administrator for it. The Secret is named mcp- plus the server name plus -auth, and the key inside it is CAI_MCP_AUTH_TOKEN.

export MCP_BEARER="cai_mcp_..." # paste yours

At this point you have: an address and a credential — everything an MCP client needs.


Act 6: call the server directly (proof one)

This is the cleanest proof that the tool works, because no model is involved. Ask the server what tools it has:

curl -s -X POST "$MCP_URL" \
-H "Authorization: Bearer $MCP_BEARER" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools[].name'

You should see:

"get_forecast"

Now run the tool:

curl -s -X POST "$MCP_URL" \
-H "Authorization: Bearer $MCP_BEARER" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"get_forecast","arguments":{"city":"Reykjavik"}}}' | jq .result

You should see a result containing your tool's return value: overcast with sleet, a high of 4 and a low of -1.

Three things about that request are worth knowing:

  • No handshake. The server is stateless: it answers tools/list and tools/call directly, with no initialize round trip and no session id. That is what makes scale-to-zero safe — any replica can answer any request.
  • Do not send MCP-Protocol-Version: 2026-07-28. You will see that header in some examples, but the MCP SDK this server runs on does not recognize that version and answers 400. Omit the header (the server assumes a supported default) or send a version it supports.
  • The Accept header must list both types. Streamable HTTP allows a server to answer with either JSON or an event stream, so clients advertise both.

Reachability, honestly: with expose: "" this endpoint is cluster-local, so the curl above runs from inside the platform network or through a port-forward your administrator sets up. If you want to call it from your laptop, create the server with "expose":"apps" and its url becomes a public HTTPS address — still bearer-protected.

At this point you have: a hosted tool proven to work over the wire.


Act 7: write an agent that uses it

An agent reaches an MCP server through one environment variable, MCP_SERVERS — a JSON list of entries with a url and a bearer. The platform SDK parses it for you with parse_mcp_servers(), so your code never hard-codes a hostname or a token.

mkdir -p weather-agent && cat > weather-agent/agent.py <<'PYEOF'
import json
import os
import urllib.request

from google.adk.agents import Agent

from crusoe_adk.foundry import foundry_model
from crusoe_core import parse_mcp_servers


def _endpoint(url):
# MCP_SERVERS entries normally already carry the /mcp path; tolerate both.
url = url.rstrip("/")
return url if url.endswith("/mcp") else url + "/mcp"


def _rpc(server, method, params):
headers = {
"Content-Type": "application/json",
# Streamable HTTP servers may answer with JSON or an event stream.
"Accept": "application/json, text/event-stream",
}
if server.get("bearer"):
headers["Authorization"] = "Bearer " + server["bearer"]
request = urllib.request.Request(
_endpoint(server["url"]),
data=json.dumps({"jsonrpc": "2.0", "id": 1,
"method": method, "params": params}).encode(),
headers=headers,
)
# Generous: an idle MCP server has to cold-start before it can answer.
with urllib.request.urlopen(request, timeout=120) as response:
return json.load(response)


def get_forecast(city: str) -> str:
"""Look up tomorrow's weather forecast for a city.

Args:
city: City name, for example "Reykjavik".

Returns:
The forecast as JSON text, or a message explaining why it is unavailable.
"""
servers = parse_mcp_servers()
if not servers:
return "No MCP server is attached to this agent."
answer = _rpc(servers[0], "tools/call",
{"name": "get_forecast", "arguments": {"city": city}})
if "error" in answer:
return "The weather tool failed: " + json.dumps(answer["error"])
return json.dumps(answer.get("result", {}))


root_agent = Agent(
name="weather_agent",
model=foundry_model(),
instruction=(
"You answer weather questions. Always call get_forecast for a city "
"rather than guessing, and report exactly what it returns. If the tool "
"says it has no forecast on file, say so."
),
tools=[get_forecast],
)
PYEOF

Deploy it:

platformctl deploy ./weather-agent --name weather-agent

You should see:

packaging ./weather-agent...
uploading weather-agent (2.0 KiB, framework=adk)...
build a0d4f912-...-uuid accepted
status: -> building
status: building -> deploying
status: deploying -> ready
weather-agent is ready at http://weather-agent.cai-p-ab12cd.svc.cluster.local
The richer client, when your image has it

This tutorial speaks MCP with the standard library so it works on the stock agent image. ADK also ships an MCPToolset that discovers a server's whole tool catalog automatically, so the model can call any tool on it without you writing a wrapper per tool — it needs the MCP SDK present in the agent image. See connect agents and clients.

At this point you have: a deployed agent that knows how to speak MCP but has not been told where the server is.


Act 8: attach the server to the agent

MCP_SERVERS carries a bearer token, so store it as a secret rather than as plain configuration:

platformctl secrets set weather-agent \
MCP_SERVERS="[{\"url\":\"$MCP_URL\",\"bearer\":\"$MCP_BEARER\"}]"

You should see:

set 1 secret(s) for weather-agent

Then one setting, which needs an explanation:

curl -s -X PATCH "$CAI_API/v1/agents/weather-agent/env" \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"set":{"TOOL_SANDBOX":"false"}}'

You should see:

{"agent":"weather-agent","env_updated":true,"env":{"TOOL_SANDBOX":"false"}}

Why TOOL_SANDBOX=false is needed here

By default the platform runs your own tool code in a single-use pod built from your agent's image but stripped of every environment variable, and blocked from private network addresses. The empty environment is the security boundary: a prompt-injected tool cannot read your keys or reach internal services.

The client function you just wrote needs both of the things that boundary removes — the MCP_SERVERS variable, and an in-cluster address. So it has to run in the agent pod. Turn the sandbox off only for tool code you wrote and trust, as here. Full details in built-in tools.

Note what is not exposed by this trade: the weather provider's own API key. That lives in project secrets and is read by the MCP server at call time — it never reaches the agent at all. That separation is much of the point of publishing tools this way.

Wait for the new revision

Each of those two commands created a new immutable revision of the agent. Wait for the newest one to serve:

platformctl status weather-agent

You should see status ready. platformctl status does not print the revision name — its Agent struct omits latest_revision even though the server returns it. To confirm the newest revision is serving, call GET /v1/agents/weather-agent directly and read latest_revision from the JSON body (it should end in -00003), or open the agent in the console.

Attachment is manual in this alpha

The platform's design is for MCP_SERVERS to be injected automatically into every agent in a project that has servers attached — the agent-side parsing already exists for exactly that. The automatic wiring is not live yet, so you set the variable yourself, as above. When injection lands, the same agent code keeps working unchanged.

At this point you have: an agent pointed at your MCP server.


Act 9: invoke the agent (proof two)

platformctl invoke weather-agent "What is the weather in Reykjavik tomorrow? Should I pack a coat?"

You should see:

Tomorrow in Reykjavik is overcast with sleet, with a high of 4C and a low of -1C. Yes - pack a coat, and something waterproof.
(session: 5e2b7a41-8c93-4f0d-b16e-3a7c9d05f2b8)
tool_call: get_forecast called with args={'city': 'Reykjavik'}

The tool_call line is the receipt: the model did not invent the weather, it called your published tool. The first call may take noticeably longer than later ones, because an idle MCP server has to cold-start before it can answer.

Now ask about a city the tool does not know:

platformctl invoke weather-agent "What is the weather in Ulaanbaatar tomorrow?"

You should see a reply saying there is no forecast on file for that city — the tool's own known: false answer, passed through honestly instead of being papered over by the model.

At this point you have: an end-to-end proof: published tool → hosted server → agent → grounded answer.


Act 10: make it real with a credential

Right now the forecast comes from a table in the source. To call a real provider you need an API key — and the whole reason to put a tool on an MCP server is that the server can hold one safely.

Store the value once in your project's secret store (see manage secrets), then declare its name on the tool and read it at call time:

import crusoe_mcp as crusoe

@crusoe.tool(credential_keys=["weather-api-key"])
def get_forecast(city: str) -> dict:
"""Tomorrow's weather forecast for a city.

Args:
city: City name, for example "Reykjavik".
"""
api_key = crusoe.secret("weather-api-key") # fetched per call, never stored
# ... call your real weather provider with api_key ...
return {"city": city, "known": True, "summary": "clear", "high_c": 24}

Publish it the same way, adding the credential key to the request:

jq -n --rawfile handler get_forecast.py \
'{handler: $handler, credential_keys: ["weather-api-key"]}' \
| curl -s -X PUT "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools/tools/get_forecast" \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d @-

Three properties worth naming:

  • Credential keys are names, not values. Nothing secret is stored on the server or baked into its image.
  • Values arrive per call, through a short-lived, read-only token. Rotating the value needs no redeploy.
  • Declaring a key does not create it. If the secret does not exist yet, crusoe.secret(...) fails at call time, not at build time.

That publish also created version 2. Look:

curl -s "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools/versions" \
-H "Authorization: Bearer $TOK" | jq '.versions[] | {version, current}'

You should see:

{"version": 2, "current": true}
{"version": 1, "current": false}

If version 2 misbehaves, go back to version 1 — no rebuild, the platform reuses that version's recorded image:

curl -s -X POST "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools/versions/1:rollback" \
-H "Authorization: Bearer $TOK"

You should see:

{"server":"weather-tools","version":1,"image":"...","rolled_back":true,"note":"re-pointed the server at version 1's recorded image; no rebuild"}

More on versions, yanking, and when each applies: versions and rollback.


Clean up

# 1. The agent
platformctl delete weather-agent

# 2. The MCP server (removes its tools and its endpoint)
curl -s -X DELETE "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools" \
-H "Authorization: Bearer $TOK"

You should see:

deleted weather-agent
{"name":"weather-tools","deleted":true}

Anything still pointed at the server's URL starts failing the moment it is deleted. If you created a weather-api-key secret for Act 10 and no longer want it, delete it from the project secret store — note that deleting a secret is refused with a 409 while any binding still references it.


If something breaks

SymptomCause and fix
400 from the MCP endpoint mentioning the protocol versionYou sent MCP-Protocol-Version: 2026-07-28. This server's SDK does not recognize it. Omit the header entirely.
401 from the MCP endpointMissing or wrong bearer. Re-read the token from the mcp-<server>-auth Secret, or ask your administrator. Every server has its own token.
Server status is failedThe build failed. The reason is in the server's message field — GET .../mcpservers/weather-tools shows it, and the console renders it in full. There is no separate build-log endpoint.
400 invalid tool name (must be a lowercase identifier not starting with '_')Tool names are lowercase letters, digits and underscores, not starting with an underscore, at most 63 characters.
400 missing 'handler': the tool's Python source (a complete @crusoe.tool module)The handler field was empty. Check that jq --rawfile actually read your file.
The agent replies "No MCP server is attached to this agent."MCP_SERVERS is not set, or the agent is still serving an older revision. Re-read Act 8 and check platformctl status weather-agent.
The agent's tool raises a KeyError or reaches nothingTOOL_SANDBOX is still on, so the tool ran in a pod with an empty environment and no access to internal addresses. Set it to false (Act 8).
409 version 2 is yanked (retired) on rollbackYanked versions cannot be rolled back to. Unyank it, or publish a new version.
409 version N is the one the server currently runs when yankingRoll traffic to another version first, then yank.
403 this action requires the project admin roleCreating servers, publishing and deleting tools, rolling back and yanking are admin actions. Listing and reading are member actions.

More: MCP servers overview and publish tools.

What you learned

IdeaThe one-sentence version
MCPAn open standard for letting an AI model discover and call your code over HTTP.
ToolA decorated Python function; its schema comes from the signature, its description from the docstring.
MCP serverA hosted, scale-to-zero endpoint that lists and runs your tools.
VersionAn immutable, digest-pinned snapshot of the server's whole tool set, made on every publish.
RollbackRe-pointing the server at an earlier version's recorded image — no rebuild.
Credential keyThe name of a project secret a tool may read at call time; the value never lives on the server.
Bearer tokenThe per-server credential (cai_mcp_ plus 64 hex characters) every caller must present.
Exposure"" keeps the server inside the platform network; "apps" gives it a public HTTPS address. Both stay authenticated.

Next steps