Publish tools
In this guide you create an MCP server, publish a complete weather tool to it, watch the build go from accepted to ready, and declare a credential key the tool reads at call time.
Before you begin
-
You need an account with the admin role on a project. Publishing tools is an admin action; ask your administrator for an account or an invitation link if you do not have one.
-
The project must be connected to Crusoe Cloud. Publishing a tool builds a container image, and that image is stored in a repository in your own Crusoe Cloud Registry. A project with no credential cannot build, and here the refusal shows up as the server's own error rather than as a failed HTTP call — see step 4. Connect it once with
platformctl crusoe-cloud connect, or Project Settings in the console; a project that is already connected needs nothing new. Details in connect your Crusoe Cloud account. -
For the
platformctltabs: the CLI installed andplatformctl loginrun. Nothing else. -
For the
curltabs: a token and your project id in your shell. The examples also usejq.export CAI_API=https://api.codyhill.devexport CAI_TOKEN=$(curl -s "$CAI_API/v1/auth/login" \-d '{"email":"you@example.com","password":"your-password"}' | jq -r .token)export PROJ=$(curl -s -H "Authorization: Bearer $CAI_TOKEN" "$CAI_API/v1/projects" | jq -r '.projects[0].id')
Step 1: Create the server
A server starts empty — it is a named container for tools. The create body has exactly two fields:
name— a lowercase DNS label, meaning it follows the rules for one piece of a web address: letters, digits, and hyphens only, starting with a letter, not ending with a hyphen, at most 40 characters.expose—""for a private server, reachable only from inside the platform network, or"apps"to publish it on a public HTTPS address.
- platformctl
- curl
- Console
platformctl mcp create weather-tools
The server is internal by default. Add --expose to publish it on the apps domain instead. Either way the endpoint authenticates.
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,...}}
Go to Compute → MCP servers in your project and click Deploy server. Give it a name and choose whether to publish it on the internet.
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: state is the word you show a person, ready is the boolean you branch on.
Real error messages 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
The create endpoint reads only name and expose. Any other fields in the body are silently ignored, and the request body is capped at 4 KiB.
Step 2: Write the tool
A tool is a plain Python function decorated with @crusoe.tool. The platform infers everything else:
- The description an MCP client shows comes from the docstring.
- The input schema (which arguments exist and their types) comes from the function signature.
credential_keyslists the names of project secrets the tool may read at call time.
Save this as get_forecast.py:
import crusoe_mcp as crusoe
@crusoe.tool(credential_keys=["weather-api-key"])
def get_forecast(city: str) -> dict:
"""Current weather for a city.
Args:
city: City name, e.g. "Reykjavik".
"""
api_key = crusoe.secret("weather-api-key") # fetched at call time, never stored
# Call your real weather provider with api_key here.
# This demo returns a stub so the example runs without an external account.
return {"city": city, "forecast": "sunny", "unit": "celsius"}
crusoe.secret("weather-api-key") fetches the secret's value fresh for each tool call, using a short-lived token. The value is never written into the image and never stored on the server.
What your tool can import
This is the single most important thing to know before you write a real tool. Get it wrong and the build still succeeds. The server then fails at startup, restarts, fails again, and keeps cycling — the first time a client asks what tools it has.
Your tool can import only what the MCP base image already ships. There is no way to add a library. The publish API writes each tool as <toolname>.py and nothing else, so a requirements.txt can never reach the build.
The base image is Python 3.12 with these libraries pinned:
| Package | Pinned version | What you would use it for |
|---|---|---|
httpx | 0.28.1 | HTTP calls — this is your only HTTP client |
pydantic | 2.13.4 | Data models and validation |
mcp | 1.29.0 | The MCP SDK (the base image uses it for you) |
starlette | 1.6.0 | The web framework underneath the server |
uvicorn | 0.52.1 | The server that runs it |
jsonschema | 4.26.0 | Schema validation |
pyjwt | 2.13.0 | Reading and writing JSON web tokens |
cryptography | 50.0.0 | Signing, hashing, encryption |
python-multipart | 0.0.32 | Multipart form parsing |
sse-starlette | 3.4.8 | Server-sent events |
The libraries those packages themselves depend on are importable too: anyio, httpcore, certifi, idna, click, attrs, typing-extensions, python-dotenv, pydantic-settings, and everything else they pull in. So is the whole Python standard library, and the platform's own crusoe_mcp package.
import requests will build fine and then break your serverrequests is not in the image. Neither are the numeric libraries numpy and pandas, nor any agent framework or data-service client library. The MCP image ships none of those on purpose, and it holds no model credential.
A tool with a missing import publishes and builds without complaint. The failure happens later, at startup, when the server tries to load your tool module. What you see from your MCP client is a server that will not answer, or a tool that has vanished from the list.
Use httpx for HTTP. It does everything requests does:
import httpx
resp = httpx.get("https://api.example.com/forecast",
params={"city": city},
headers={"Authorization": f"Bearer {api_key}"},
timeout=10.0)
resp.raise_for_status()
return resp.json()
If your workload requires additional custom dependencies or heavy data processing libraries, deploy the business logic as an agent or serverless service with a custom requirements.txt, and invoke it from your MCP tool using httpx.
What your tool's environment carries
No model credential, and no client library the image did not need — but the platform's own REST surfaces by name, at internal addresses that resolve only from inside the platform. This is exactly why a tool that must reach a platform service belongs in an MCP server rather than in an agent, whose tools run in a sandbox with an empty environment:
| Variable | What the container gets |
|---|---|
CAI_API_URL | The platform API, at its internal address |
CAI_VECTORDB_URL | The Vectors REST API, at its internal address |
CAI_PUBSUB_URL | The Pub/Sub REST API, at its internal address |
EMBED_BASE_URL | The embedding endpoint, at its internal address — already ending in /v1 |
EMBED_MODEL | The project's embedding model, e.g. qwen-embedding |
CAI_PROJECT_ID | Your project's id, so a tool never hard-codes a UUID |
CAI_PROJECT_KEY | The workload's own key, injected from a Secret — what crusoe.secret() mints its short-lived read token with |
CRUSOE_REQUEST_TIMEOUT_SECONDS | The request budget, written by the operator from the server's timeout_seconds so the two can never drift |
Read the addresses from those variables rather than hard-coding them, and call them with httpx. One trap in the list: CAI_PROJECT_KEY is not a general-purpose credential. Project APIs such as Vectors reject it by design; its one power is minting the token crusoe.secret() reads through. A tool that queries an index needs a real service-account key, stored as a project secret and named in the tool's credential_keys.
crusoe.run_python() is present but not wired upcrusoe_mcp exports run_python, so crusoe.run_python(...) looks right in review and imports cleanly. It still cannot work inside a deployed MCP server today. SANDBOX_URL — the one address it needs — is not among the variables injected into an MCP container, so every call raises:
SandboxError: crusoe.run_python is not available in this deployment: SANDBOX_URL is not injected. This MCP server's env carries only the secret-minting set.
Read that message's first sentence and ignore its second: the env is not "only the secret-minting set" — it carries the REST surfaces listed above too. SANDBOX_URL specifically is the one that is missing.
The failure is at runtime, on the first call, surfacing through whatever MCP client made the request. Do not build a tool around it. To run untrusted code today, use the code sandbox from an agent, where the address is injected.
Step 3: Publish the tool
Publishing is a PUT to tools/{tool}. The tool name in the URL becomes the filename of the module (tools/get_forecast.py), so it must be a lowercase Python identifier: letters, digits, and underscores, not starting with an underscore, at most 63 characters.
- platformctl
- curl
- Console
--handler takes the tool's Python source: a literal string, @path to read a file, or - to read standard input.
platformctl mcp tools set weather-tools get_forecast \
--handler @get_forecast.py \
--description "Current weather for a city" \
--credential-key weather-api-key
A field you do not pass keeps its current value — the tool is read back first and whatever you left out is re-sent unchanged. Clearing is explicit: --description "" empties the description, and --credential-key= removes every credential key.
jq -n --rawfile handler get_forecast.py \
'{handler: $handler, description: "Current weather for a city", 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 @-
You should see:
{"server":"weather-tools","name":"get_forecast","published":true,"build_id":"<uuid>","note":"building a new immutable version from the server's current tool set"}
Unlike the CLI, a raw PUT replaces the whole tool record — a field you omit is not carried over.
Open the server and click Publish tool. Paste the handler source, give it a description, and list any credential keys it reads.
That 202 Accepted — the HTTP status for "taken on, not finished yet" — means the platform snapshotted the server's entire tool set and started building version 1 in the background.
Request fields:
| Field | Type | Required | Notes |
|---|---|---|---|
handler | string | yes | The tool's complete Python source (a @crusoe.tool module) |
description | string | no | One-line prose shown in tool listings |
schema | object | no | Explicit JSON Schema for the arguments; omitted = inferred from the signature |
credential_keys | array of strings | no | Names of project secrets the tool may read (credentialKeys is accepted as an alias) |
The publish body is capped at 1 MiB. Real error messages:
400—missing 'handler': the tool's Python source (a complete @crusoe.tool module)400—invalid tool name (must be a lowercase identifier not starting with '_')
Step 4: Watch the build
Builds run in the background, so the publish call has already returned. Re-read the server every few seconds until its ready turns true:
- platformctl
- curl
- Console
platformctl mcp get weather-tools
curl -s "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools" \
-H "Authorization: Bearer $CAI_TOKEN" | jq '.mcp_server | {state, ready}'
The server's page shows its state, and refreshes on its own while a build is running.
You should see (after a minute or two):
{
"state": "ready",
"ready": true
}
failed before any build outputPublishing answers 202 whether or not the project can build, so a missing Crusoe Cloud connection lands on the server row instead of on the call you made. The error begins:
this project cannot deploy yet: its container images are built into your own Crusoe Cloud
container registry, and no Crusoe Cloud credential is mapped to this project.
Nothing was built and your tools were not lost. Connect the project and publish again.
The full object now carries the endpoint and version:
curl -s "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools" \
-H "Authorization: Bearer $CAI_TOKEN" | jq .
You should see:
{"mcp_server":{"name":"weather-tools","state":"ready","ready":true,"url":"...","version":1,"tool_names":["get_forecast"],"tool_count":1,...}}
The lifecycle is pending → building → deploying → ready, or failed, and ready is true only at the end of that road. If a build fails, state is failed, ready stays false, and the reason lands in the server's message field — there is no separate build-log endpoint.
Step 5: Set the credential value
Declaring credential_keys names the secret. It does not create it. Store the actual value once in your project's secret store — see manage secrets. The running tool then fetches the value by name each time it is called. If the secret does not exist yet, the tool's crusoe.secret(...) call fails when the tool runs, not when it builds.
Step 6: Verify the published tool set
- platformctl
- curl
- Console
platformctl mcp tools list weather-tools
curl -s "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools/tools" \
-H "Authorization: Bearer $CAI_TOKEN" | jq .
You should see:
{"server":"weather-tools","tools":[{"name":"get_forecast","description":"Current weather for a city","handler":"...","schema":{},"credential_keys":["weather-api-key"],...}]}
The server's page lists every published tool with its description and credential keys, and offers Edit and Delete on each.
Update and remove tools
- Update:
PUTthe same tool name again with new source. Every publish builds a fresh, unchangeable version of the whole tool set, not just the tool you touched. - Remove: delete the tool; this also triggers a rebuild:
- platformctl
- curl
- Console
platformctl mcp tools delete weather-tools get_forecast
curl -s -X DELETE "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools/tools/get_forecast" \
-H "Authorization: Bearer $CAI_TOKEN"
You should see:
{"server":"weather-tools","name":"get_forecast","deleted":true,"build_id":"<uuid>","note":"rebuilding the server without this tool"}
Use the Delete action on the tool's row.
Deleting a tool that does not exist returns 404.
Clean up
Deleting the server removes its tools and its endpoint. Anything pointed at its URL starts failing immediately.
- platformctl
- curl
- Console
platformctl mcp delete weather-tools
curl -s -X DELETE "$CAI_API/v1/projects/$PROJ/mcpservers/weather-tools" \
-H "Authorization: Bearer $CAI_TOKEN"
You should see:
{"name":"weather-tools","deleted":true}
Open the server and click Delete, then confirm.
Next steps
- Versions and rollback — every publish you just did created a version; learn to roll between them.
- Connect agents and clients — call
get_forecastfrom an agent or an external MCP client. - Tutorial: weather tools over MCP — the end-to-end walkthrough.