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 and proved it works by calling it directly. Then you will wire an agent to it, so the model calls the tool on its own. Finally you will publish a second 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 is whatever calls it — an agent, a chat app, a coding assistant. 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. It is 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 makes a version you can return to with one call. Each version is pinned to a digest: the content hash that names one exact image build. So going back gives you those precise bytes, not a rebuild.

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.
  • Your project connected to Crusoe Cloud. Everything you deploy is built into a container image, and that image is stored in a repository in your own Crusoe Cloud Registry — so a project with no Crusoe Cloud credential is refused before anything is built. Connecting is a one-time, project-admin step, and a project that is already connected needs nothing new. See connect your Crusoe Cloud account.

Most of this tutorial talks to the API with curl, so point CAI_API at the public API:

export CAI_API="https://api.codyhill.dev"
# $CAI_TOKEN is cached by 'platformctl login'
export PROJ="00000000-0000-0000-0000-000000000000" # your project UUID

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

export CAI_TOKEN=$(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. Your project's UUID — a long unique id — comes from one command:

platformctl projects list

You should see:

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

The ID column is the UUID to export. The SHORT column is the six-character project id that shows up inside your servers' addresses — ab12cd in the examples below.

Every management step is shown three ways — platformctl, curl, and the console. Pick whichever tab suits you and stay in it; your choice follows you across every page in these docs. The curl tab shows each request and response in full, which is useful here because MCP itself is an HTTP protocol.

One part of this tutorial is deliberately not tabbed: Act 6 talks to the MCP endpoint itself. That is the protocol, not the platform's management API, so curl is the only sensible way to show it.


Act 1: create the server

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

platformctl mcp create weather-tools

The server is private by default. Add --expose to publish it on a public HTTPS address instead.

The two fields that matter:

  • name — a lowercase DNS label at most 40 characters long. A DNS label is one piece of a hostname, the part between the dots. So the name may use only letters, digits, and hyphens, and it must start with a letter. It is set once and never changes.
  • expose"" keeps the server private, reachable only from inside the platform network. "apps" publishes it on a public HTTPS address. Either way the endpoint requires a bearer token. Exposure changes who can reach the door, not whether it is locked.

state is pending and ready is false because a server with no tools has nothing to build yet. Those two fields travel together on every resource the platform serves: state is the word to show, in this resource's own vocabulary, and ready is the boolean to branch on.

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 "" (reachable only from inside the platform) or "apps" (published on the internet)
  • 409an mcp server named weather-tools already exists in this project

At this point you have: an empty server named weather-tools, state 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 sends your Python source to the tool's path on the server.

--handler takes @ followed by a file path:

platformctl mcp tools set weather-tools get_forecast \
--handler @get_forecast.py \
--description "Tomorrow's weather forecast for a city"

Ask for the server again until it reports ready:

platformctl mcp get weather-tools

You should see, after a minute or two, state ready, ready true, and version 1, with get_forecast listed among the tools. platformctl mcp list shows the same word in its STATE column.

That 202 Accepted means the platform took a snapshot of 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. That is what makes a version something you can trust: it is the complete set, not a diff you have to reason about.

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: the tool catalog, and the version history.

platformctl mcp tools list weather-tools
platformctl mcp versions weather-tools

You should see get_forecast with the description the platform read from your docstring, and a single version 1 marked as current.

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.

platformctl mcp get weather-tools

Read the url field, and append /mcp:

export MCP_URL="http://<private-hostname>/mcp" # paste yours

The bearer token is the credential every caller must send, in an Authorization: Bearer header. The platform mints one per server, in the format cai_mcp_ followed by 64 hexadecimal characters. It is injected into the server so the server can check callers. It is deliberately not returned by any API. The console shows it as a placeholder, never a value.

Obtaining the token

Obtain the per-server bearer token for weather-tools from your project settings or administrator if needed. Every server has its own 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. You talk to the server in JSON-RPC: a JSON body that names a method and its params, answered with a result or an error. Start by asking 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 remembers nothing between calls. 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, because any copy of the server can answer any request.
  • Do not send MCP-Protocol-Version: 2026-07-28. You will see that header in some examples. The MCP SDK this server runs on does not recognize that version and answers 400. Omit the header, in which case the server assumes a supported default, or send a version it supports.
  • The Accept header must list both types. MCP rides on a transport called Streamable HTTP. On it, a server may answer with either one JSON body or a stream of events. Clients therefore advertise that they accept both.

Reachability, honestly: with expose: "" this endpoint is private. The curl above only works from inside the platform's own network — from another workload, for instance. Want to call it from your laptop? Create the server with "expose":"apps" and its url becomes a public HTTPS address, still protected by the bearer token.

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 — and the platform sets it, not you. Its value is a JSON list with one entry per ready MCP server in the project, each carrying a name, a url and a bearer. The platform SDK parses it for you with parse_mcp_servers(), so your code never hard-codes a hostname, and the token never appears in your source.

The JSON-RPC client below is identical in all three frameworks — only the way the tool is declared changes.

mkdir -p weather-agent && cat > weather-agent/agent.py <<'PYEOF'
import json
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 _call_forecast(city):
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", {}))


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.
"""
return _call_forecast(city)


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

ADK reads the tool's schema from the signature and its description from the docstring, so the Args: section is what the model sees.

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
state: -> building
state: building -> deploying
state: deploying -> ready
weather-agent is ready at https://weather-agent-ab12cd.apps.codyhill.dev
The one-line version

The hand-written client above is here to show you the protocol. You do not have to write one. The stock ADK image installs google-adk[mcp], so from crusoe_adk.mcp import mcp_toolsets works out of the box: tools=[*mcp_toolsets()] attaches every tool on every attached server, and a tool published later needs no agent change. CrewAI has crusoe_crewai.mcp_tools(). LangGraph's crusoe_langchain.mcp_tools() is a coroutine — await it, or wrap it in asyncio.run(...) at module scope. All three take names= to narrow the attachment to servers you name, and raise crusoe_core.mcp.UnknownMCPServer on a name that is not attached rather than quietly attaching nothing.

The short form end to end: Agent + Weather MCP. Mechanics: 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

You do not wire up MCP_SERVERS. The platform lists the project's ready MCP servers and injects the variable into every agent it builds — each server's private in-project URL with /mcp already appended, plus its minted bearer. Setting it by hand looks like it worked and is not delivered: the platform writes MCP_SERVERS directly onto the workload, whereas an agent secret is merged in from the bound-secret bundle, and a directly-set variable always beats the bundle. The name is locked on the env route for the same reason, so PATCH /env refuses it outright.

So this act is two things: confirm the server is ready, then roll a revision that picks it up. The one variable you do set is TOOL_SANDBOX=false, explained below — and it has to be an agent secret, because the env route refuses that name too.

platformctl mcp get weather-tools # ready must read true first
platformctl secrets set weather-agent TOOL_SANDBOX=false

You should see:

set 1 secret(s) for weather-agent

Why TOOL_SANDBOX=false is needed here

By default the platform runs your own tool code in a single-use sandbox, which is one throwaway container. It is built from your agent's image, but every environment variable is stripped out and private network addresses are blocked. The empty environment is the security boundary. A tool that has been talked into misbehaving by a crafted prompt cannot read your keys or reach internal services.

The client function you just wrote needs exactly the two things that boundary removes: the MCP_SERVERS variable, and the ability to reach an address inside the platform network. So it has to run in the agent itself. Turn the sandbox off only for tool code you wrote and trust, as here. Full details in built-in tools.

Note what this trade does not expose: 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

Setting that secret created a new revision of the agent — a frozen snapshot of its code and its settings, including the MCP_SERVERS the platform computed at the moment the revision was built. That is the part worth remembering: an MCP server that becomes ready after an agent's newest revision was built is not in it. Any change that rolls a revision picks the server up — a secret, an env variable, a redeploy.

Wait for the newest one to serve:

platformctl status weather-agent

You should see state ready and ready true. platformctl status does not print the revision name, because its output 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 -00002, the deploy in Act 7 being -00001. Or open the agent in the console.

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. Expect the first call to 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:

What is the weather in Ulaanbaatar tomorrow?

You should see a reply saying there is no forecast on file for that city. That is 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. Calling a real provider needs an API key. This is the whole reason to put a tool on an MCP server: the server can hold that key 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:

platformctl mcp tools set weather-tools get_forecast \
--handler @get_forecast.py \
--credential-key weather-api-key

--credential-key is repeatable. Leaving it off keeps the tool's current keys.

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 at the history, then go back to version 1 — there is no rebuild, because the platform reuses that version's recorded image.

platformctl mcp versions weather-tools

You should see version 2 marked as current and version 1 below it. Roll back:

platformctl mcp rollback weather-tools 1

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


Clean up

platformctl delete weather-agent
platformctl mcp delete weather-tools

You should see:

deleted weather-agent

Anything still pointed at the server's URL starts failing the moment it is deleted. Did you create a weather-api-key secret for Act 10 and no longer want it? Delete it from the project secret store. 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. Ask your administrator for this server's token — every server has its own, and no API returns it.
Server state 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."The MCP server was not ready when the agent's current revision was built, so the platform injected an empty MCP_SERVERS. Nothing is missing from your secrets. Confirm platformctl mcp get weather-tools reads ready true, then roll a revision (any secret or env change, or a redeploy) 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 sandbox with an empty environment and no access to internal addresses. Set it to false as an agent secretplatformctl secrets set weather-agent TOOL_SANDBOX=false (Act 8). The env route answers 400 for that name.
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.
JSON-RPCThe request format MCP uses: a JSON body naming a method and its params, answered with a result or an error.
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.
VersionA frozen snapshot of the server's whole tool set, made on every publish and pinned to one exact image build.
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

Go deeper

These advanced guides pick up where the quickstarts stop, each exercising a different slice of the platform:

GuideFramework / language
Multi-step research agentLangGraph
Editorial pipeline with a crewCrewAI
Support agent over your own docsADK
Document ingestion pipelinePython
Webhook fan-out, exactly onceNode.js
Scheduled reconciliation jobGo
Object-store ETL with move-after-readRuby