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.curlandjq.- 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
- curl
- Console
platformctl mcp create weather-tools
The server is private by default. Add --expose to publish it on a public HTTPS address instead.
curl -s -X POST "$CAI_API/v1/projects/$PROJ/mcpservers" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"weather-tools","expose":""}'
You should see:
{"mcp_server":{"name":"weather-tools","expose":"","state":"pending","ready":false,"tool_names":[],"tool_count":0,"created_at":"...","visibility":"..."}}
Go to Compute → MCP servers and create a server named weather-tools, leaving it private.
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.
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:
400—missing 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)409—an 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.
- platformctl
- curl
- Console
--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.
The raw call is a PUT. 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 $CAI_TOKEN" -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"}
Ask for the server again until it reports ready:
curl -s "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools" \
-H "Authorization: Bearer $CAI_TOKEN" | jq '.mcp_server | {state, ready, version, url, tool_names}'
You should see, after a minute or two:
{
"state": "ready",
"ready": true,
"version": 1,
"url": "http://<private-hostname>",
"tool_names": ["get_forecast"]
}
message is left out of the response entirely when there is nothing to report; it appears, carrying the build's own words, when state is failed.
Open weather-tools under Compute → MCP servers, add a tool named get_forecast, and paste the contents of get_forecast.py as its handler.
You should see: the server move through building and deploying to ready, reporting version 1 with get_forecast in its tool list.
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 pending → building → deploying → ready, 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
- curl
- Console
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.
curl -s "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools/tools" \
-H "Authorization: Bearer $CAI_TOKEN" | 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 $CAI_TOKEN" | jq '.versions[] | {version, current, tool_names, created_at}'
You should see:
{
"version": 1,
"current": true,
"tool_names": ["get_forecast"],
"created_at": "..."
}
The server's page lists its tools with the description the platform read from your docstring, and its version history with 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
- curl
- Console
platformctl mcp get weather-tools
Read the url field, and append /mcp:
export MCP_URL="http://<private-hostname>/mcp" # paste yours
export MCP_URL="$(curl -s "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools" \
-H "Authorization: Bearer $CAI_TOKEN" | jq -r '.mcp_server.url')/mcp"
echo "$MCP_URL"
You should see:
http://<private-hostname>/mcp
The server's page shows its address. Append /mcp to it — that is the endpoint MCP clients call.
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.
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/listandtools/calldirectly, with noinitializeround 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 answers400. Omit the header, in which case the server assumes a supported default, or send a version it supports. - The
Acceptheader 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.
- ADK
- CrewAI
- LangGraph
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.
mkdir -p weather-agent && cat > weather-agent/crew.py <<'PYEOF'
import json
import urllib.request
from crewai import Agent, Crew, Process, Task
from crewai.tools import tool
import crusoe_crewai as crusoe
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)
@tool("get_forecast")
def get_forecast(city: str) -> str:
"""Look up tomorrow's weather forecast for a 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", {}))
weather_agent = Agent(
role="Weather Agent",
goal="Answer weather questions using the published forecast tool.",
backstory="A precise assistant that never guesses at the weather.",
llm=crusoe.foundry_model(),
tools=[get_forecast],
verbose=False,
)
respond = Task(
description=(
"Prior conversation (may be empty on the first turn):\n{history}\n\n"
"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.\n\n{message}"
),
expected_output="A weather answer taken from the tool's output, or a plain statement that no forecast is on file.",
agent=weather_agent,
)
crew = Crew(agents=[weather_agent], tasks=[respond], process=Process.sequential)
PYEOF
Use @tool on a module-level function. A BaseTool subclass — CrewAI's own documented shape — stops the agent from starting under the default TOOL_SANDBOX=true. See CrewAI.
mkdir -p weather-agent && cat > weather-agent/graph.py <<'PYEOF'
import json
import urllib.request
from langchain_core.tools import tool
from langgraph.prebuilt import create_react_agent
import crusoe_langchain as crusoe
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)
@tool
def get_forecast(city: str) -> str:
"""Look up tomorrow's weather forecast for a 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", {}))
graph = create_react_agent(
crusoe.foundry_model(),
tools=[get_forecast],
prompt=(
"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."
),
)
PYEOF
Use @tool on a module-level, synchronous function. Class-based or async def tools fail at startup. See LangGraph.
Deploy it:
- platformctl
- curl
- Console
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
tar -czf weather-agent.tar.gz -C weather-agent .
curl -s -X POST "$CAI_API/v1/agents" \
-H "Authorization: Bearer $CAI_TOKEN" \
-F "name=weather-agent" \
-F "framework=adk" \
-F "code=@weather-agent.tar.gz"
Use framework=crewai or framework=langgraph to match the tab you wrote above, then poll:
curl -s -H "Authorization: Bearer $CAI_TOKEN" \
"$CAI_API/v1/agents/weather-agent" | jq -r '.state, .message'
Go to Compute → Agents → Deploy agent, name it weather-agent, pick the framework you wrote above, and upload the weather-agent folder. Click through Review & deploy and watch the build panel reach ready.
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
- curl
- Console
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
curl -s "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools" \
-H "Authorization: Bearer $CAI_TOKEN" | jq '.mcp_server.ready'
curl -s -X PATCH "$CAI_API/v1/agents/weather-agent/secrets" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'Content-Type: application/json' \
-d '{"set":{"TOOL_SANDBOX":"false"}}'
You should see:
true
{"agent":"weather-agent","secrets_updated":true,"keys":["TOOL_SANDBOX"]}
Send that same body to /env instead and you get a 400 naming the rule, with nothing written.
Nothing to add here. MCP_SERVERS is injected for you — open the server's page and confirm it reads ready first — and the other variable is one the console will not take.
TOOL_SANDBOX cannot be set from the consoleTwo refusals, both deliberate, on the two forms you might try. As plain configuration the API refuses it: PATCH /env answers 400 — the name is reserved, and a value stored there would be discarded at deploy anyway. On the secret form, where it would take effect, the console refuses it by name before the request leaves your browser, because turning the sandbox off is not something to do by typing a variable into a list. Finish this act from the platformctl or curl tab:
platformctl secrets set weather-agent TOOL_SANDBOX=false
Read the next section before you run it.
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
- curl
- Console
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'}
curl -s -X POST "$CAI_API/v1/agents/weather-agent/invoke" \
-H 'content-type: application/json' \
-d '{"message":"What is the weather in Reykjavik tomorrow? Should I pack a coat?"}' \
| jq -r '.output, .tool_calls[].name'
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.
get_forecast
Open the agent's Test tab and ask:
What is the weather in Reykjavik tomorrow? Should I pack a coat?
You should see the answer and a get_forecast tool call entry.
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
- curl
- Console
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.
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 $CAI_TOKEN" -H 'Content-Type: application/json' -d @-
Open the tool on the server's page, replace its handler with the new source, and add weather-api-key to its credential 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
- curl
- Console
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
curl -s "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools/versions" \
-H "Authorization: Bearer $CAI_TOKEN" | jq '.versions[] | {version, current}'
You should see:
{"version": 2, "current": true}
{"version": 1, "current": false}
Roll back:
curl -s -X POST "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools/versions/1:rollback" \
-H "Authorization: Bearer $CAI_TOKEN"
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"}
The server's version history lists both versions, with version 2 marked current. Select version 1 and roll back to it from there.
More on versions, yanking, and when each applies: versions and rollback.
Clean up
- platformctl
- curl
- Console
platformctl delete weather-agent
platformctl mcp delete weather-tools
You should see:
deleted weather-agent
# 1. The agent
curl -s -X DELETE "$CAI_API/v1/agents/weather-agent" \
-H "Authorization: Bearer $CAI_TOKEN"
# 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 $CAI_TOKEN"
You should see:
{"agent":"weather-agent","deleted":true}
{"name":"weather-tools","deleted":true}
Delete the agent from its page, then delete weather-tools from Compute → MCP servers.
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
| Symptom | Cause and fix |
|---|---|
400 from the MCP endpoint mentioning the protocol version | You sent MCP-Protocol-Version: 2026-07-28. This server's SDK does not recognize it. Omit the header entirely. |
401 from the MCP endpoint | Missing or wrong bearer. Ask your administrator for this server's token — every server has its own, and no API returns it. |
Server state is failed | The 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 nothing | TOOL_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 secret — platformctl secrets set weather-agent TOOL_SANDBOX=false (Act 8). The env route answers 400 for that name. |
409 version 2 is yanked (retired) on rollback | Yanked versions cannot be rolled back to. Unyank it, or publish a new version. |
409 version N is the one the server currently runs when yanking | Roll traffic to another version first, then yank. |
403 this action requires the project admin role | Creating 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
| Idea | The one-sentence version |
|---|---|
| MCP | An open standard for letting an AI model discover and call your code over HTTP. |
| JSON-RPC | The request format MCP uses: a JSON body naming a method and its params, answered with a result or an error. |
| Tool | A decorated Python function; its schema comes from the signature, its description from the docstring. |
| MCP server | A hosted, scale-to-zero endpoint that lists and runs your tools. |
| Version | A frozen snapshot of the server's whole tool set, made on every publish and pinned to one exact image build. |
| Rollback | Re-pointing the server at an earlier version's recorded image — no rebuild. |
| Credential key | The name of a project secret a tool may read at call time; the value never lives on the server. |
| Bearer token | The 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
- MCP servers overview — the mental model and how it compares to the big clouds.
- Publish tools — the reference walkthrough for the publish path.
- Versions and rollback — yanking, pinning, and recovery.
- Connect agents and clients — attaching servers to agents and to external MCP clients.
- Manage secrets — storing the credential your tool reads.
- Tutorial: research agent with memory — sessions, memorize, and the built-in tools.
Go deeper
These advanced guides pick up where the quickstarts stop, each exercising a different slice of the platform:
| Guide | Framework / language |
|---|---|
| Multi-step research agent | LangGraph |
| Editorial pipeline with a crew | CrewAI |
| Support agent over your own docs | ADK |
| Document ingestion pipeline | Python |
| Webhook fan-out, exactly once | Node.js |
| Scheduled reconciliation job | Go |
| Object-store ETL with move-after-read | Ruby |