Skip to main content

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 platformctl tabs: the CLI installed and platformctl login run. Nothing else.

  • For the curl tabs: a token and your project id in your shell. The examples also use jq.

    export CAI_API=https://api.codyhill.dev
    export 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 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.

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:

  • 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
Only two fields are read

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_keys lists 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:

PackagePinned versionWhat you would use it for
httpx0.28.1HTTP calls — this is your only HTTP client
pydantic2.13.4Data models and validation
mcp1.29.0The MCP SDK (the base image uses it for you)
starlette1.6.0The web framework underneath the server
uvicorn0.52.1The server that runs it
jsonschema4.26.0Schema validation
pyjwt2.13.0Reading and writing JSON web tokens
cryptography50.0.0Signing, hashing, encryption
python-multipart0.0.32Multipart form parsing
sse-starlette3.4.8Server-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 server

requests 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:

VariableWhat the container gets
CAI_API_URLThe platform API, at its internal address
CAI_VECTORDB_URLThe Vectors REST API, at its internal address
CAI_PUBSUB_URLThe Pub/Sub REST API, at its internal address
EMBED_BASE_URLThe embedding endpoint, at its internal address — already ending in /v1
EMBED_MODELThe project's embedding model, e.g. qwen-embedding
CAI_PROJECT_IDYour project's id, so a tool never hard-codes a UUID
CAI_PROJECT_KEYThe workload's own key, injected from a Secret — what crusoe.secret() mints its short-lived read token with
CRUSOE_REQUEST_TIMEOUT_SECONDSThe 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 up

crusoe_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.

--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.

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:

FieldTypeRequiredNotes
handlerstringyesThe tool's complete Python source (a @crusoe.tool module)
descriptionstringnoOne-line prose shown in tool listings
schemaobjectnoExplicit JSON Schema for the arguments; omitted = inferred from the signature
credential_keysarray of stringsnoNames of project secrets the tool may read (credentialKeys is accepted as an alias)

The publish body is capped at 1 MiB. Real error messages:

  • 400missing 'handler': the tool's Python source (a complete @crusoe.tool module)
  • 400invalid 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 mcp get weather-tools

You should see (after a minute or two):

{
"state": "ready",
"ready": true
}
If the state goes to failed before any build output

Publishing 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 pendingbuildingdeployingready, 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 mcp tools list weather-tools

Update and remove tools

  • Update: PUT the 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 mcp tools delete weather-tools get_forecast

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 mcp delete weather-tools

Next steps