Skip to main content

Connect agents and clients

Your MCP server is built and ready. This page shows you where its endpoint lives and what the per-server token is — including why you never get to see it. Then it shows you how to call the server's tools, both from an agent running on the platform and from an MCP client anywhere else.

Before you begin

  • You need a project member role to read a server's endpoint.
  • You need a server whose state is ready — equivalently, whose ready is true. A server that is still pending, building, or deploying has no endpoint yet. See publish tools.
  • The API is at https://api.codyhill.dev.

Sign in and capture a token and your project id:

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')

Find the endpoint

The server object carries a url field once the platform has deployed it.

platformctl mcp get weather-tools

Whatever address you use, the protocol endpoint is that address with /mcp on the end. The MCP protocol is mounted at /mcp; the bare URL is the service, not the protocol endpoint. Without the path a client connects, POSTs to /, and gets a 404 that surfaces as Failed to create MCP session — the network is fine and the address is wrong, which are hard to tell apart from the client's side.

For a published server, that address is the url field:

https://mcp-<name>-<short>.apps.codyhill.dev/mcp

For a private server, the url field is not the address to use at all — see the warning below.

If url is empty, the server has not finished deploying. Wait and poll again — do not guess the address.

url is not the address a workload should call

For a private server, url is an internal name that resolves to the platform's shared ingress layer. Egress from your project to that layer is deliberately closed — it used to be the cross-tenant Host-header bypass — so a workload in your project cannot reach it. A call to url + /mcp from inside a project hangs until the request budget expires rather than returning a useful error.

The address that works from inside your project is the one the platform injects into MCP_SERVERS. It already carries the /mcp path, and it is private to your project. Read it from MCP_SERVERS rather than constructing it — its shape is ours to change, and the variable always holds the current one. The url field is for humans, and for external clients on a published (expose: apps) server.

Private or published

You chose the server's exposure when you created it, with the expose field. It is one of exactly two values, and there is no endpoint that changes it afterwards — to switch, delete the server and create it again.

exposeWhat it meansAddressWho can reach it
"" (the default)Private. The server answers only on the platform's internal network.An internal address, private to your project, handed to your workloads in MCP_SERVERS with /mcp already on it — the url field shows a different internal name, which a workload cannot reachWorkloads in this project, via the address in MCP_SERVERS
"apps"Published. The server gets a public HTTPS address.https://mcp-<name>-<short>.apps.codyhill.dev/mcpAnything on the internet that has the token

Here <name> is your server's name and <short> is your project's short id — a permanent id fragment the platform assigns when the project is created. Renaming the project never changes it, so these addresses are stable. Published addresses follow the same pattern as every other workload; see public endpoints and domains.

Private is not the same as unauthenticated

A private address is not on the internet, and other tenants are kept off it at the network layer: a project's egress policy admits DNS, the platform's own control-plane services and the authenticated apps gateway, and deliberately omits the ingress layer precisely because Host-header routing made it a cross-tenant bypass. Traffic arriving from another project is denied in the other direction too.

The bearer token is still required on every request, whichever exposure you picked, because a private address is not an identity: anything running in your own project can reach it. Do not treat "internal" as "safe to leave open" — the platform does not.

A private URL is also not something you can open in a browser. The console prints it as copyable text rather than a link, for that reason.

The per-server bearer token

Every MCP server has its own token. The format is cai_mcp_ followed by 64 hexadecimal characters. Hexadecimal writes each byte as two characters drawn from 09 and af, so 64 of them is 32 random bytes:

cai_mcp_3f9c1a... (64 hex characters in total after the prefix)

Four things to know about it:

  • It is per server, not per user. It is a shared secret between the server and whoever calls it, not one of your API keys. It carries no identity and no project role.
  • It is created once and reused. The platform creates it the first time the server deploys, then reuses the same value across every later publish, redeploy, and rollback. A rebuild does not replace it, so callers that already hold it keep working.
  • The server fails closed. If the token is missing from the server's own environment, the server refuses every request with 503 rather than serving openly. Failing closed means denying by default when something is wrong.
  • Nothing returns it to you. No API response contains it, and the console shows only a placeholder. The value travels from the platform into the server, which uses it to check callers, and into attached workloads, which use it to call. Nowhere else.
Connecting External MCP Clients

Because bearer tokens are securely generated at deployment and never returned in API reads, external clients must be provisioned with the server's bearer token by a project administrator. Platform-hosted agents automatically receive bearer tokens via the injected MCP_SERVERS environment variable.

Every request carries it as a normal bearer credential:

Authorization: Bearer cai_mcp_...

Call it from a platform agent

An agent should never hard-code the URL or the token. The platform hands each workload an MCP_SERVERS environment variable instead. Its value is a JSON list of objects, each with a name, a url and a bearer. The url here is the internal address for this project, with /mcp already on the end — not the server object's url field. Treat it as opaque: read it, do not rebuild it.

[{"name": "weather-tools",
"url": "http://<internal-address>/mcp",
"bearer": "cai_mcp_..."}]

The list holds every MCP server in the project whose state is ready, sorted stably so an unchanged project does not roll a new revision. There is no attach action anywhere — no flag, no field, no console control; being ready in the project is what attaches a server.

The list is captured when the agent is deployed

MCP_SERVERS is written into the agent's revision at deploy time, not read live. A server created — or first reaching ready — afterwards does not reach an already-deployed agent. Build the server first, wait for platformctl mcp get <server> to read ready, then deploy the agent. An agent deployed too early starts cleanly with no tools and says nothing about it, and there is no way to read MCP_SERVERS back to check: GET /v1/agents/{name}/env returns only the variables you set yourself. Redeploying the agent is both the check and the fix.

Every harness image ships crusoe_core.parse_mcp_servers(), which reads that variable and returns a tidied-up list. It handles the two edge cases deliberately:

  • If the variable is missing or blank — the common case — it returns an empty list, so an agent with no MCP servers still starts.
  • If the JSON is malformed, it raises ValueError. A misconfiguration fails loudly at startup rather than quietly attaching no tools.

Each framework turns that list into its own kind of tool. Below is a complete agent for each, attaching every server it is given alongside the local sandboxed run_python tool.

crusoe_adk.mcp_toolsets() wraps ADK's MCPToolset and is synchronous. Spread it into tools=[...]:

from google.adk.agents import Agent

from crusoe_adk import foundry_model, mcp_toolsets
from crusoe_adk.tools import run_python

root_agent = Agent(
name="adk_mcp",
model=foundry_model(),
instruction=(
"You are an assistant with access to tools from a Crusoe MCP server. "
"Prefer an MCP tool when one fits the request, and use run_python for "
"arbitrary computation."
),
tools=[run_python, *mcp_toolsets()],
)

It returns one toolset per attached server, and attaches every tool each server publishes. No servers means an empty list, so the call is always safe; a server configured on a base image without ADK's mcp extra fails loudly at startup rather than silently attaching nothing.

MCP tools are never moved into a tool sandbox

An MCP server runs the tool on its own machine, so there is no tool body for the platform to relocate. All three harnesses recognize MCP tools and leave them alone — which is correct, and not a gap in tool sandboxing.

The flip side is worth naming: the client code that carries the request and reads the reply does run inside your agent, next to its credentials. A compromised MCP server is talking to code that holds them.

Attach only the servers you name

By default the agent code names no server at all: change which servers the project has, deploy the agent again, and the same code picks up the new list. That is the right default for most agents — publishing a new tool to an attached server then needs no agent change.

When it is not the right default — an agent that should not see every tool in the project, or one that must not have its toolset widened the day somebody else deploys a server — all three helpers take a names= argument: a single name, or an iterable of them.

tools=[run_python, *mcp_toolsets()] # every attached server
tools=[run_python, *mcp_toolsets(names="weather-tools")] # just that one
tools=[run_python, *mcp_toolsets(names=["weather-tools", "doc-search-mcp"])] # those two

Two shapes worth knowing before you type one: a bare string is one name, not an iterable of characters, and an explicit empty list attaches nothing — the caller asked for nothing, so they get nothing rather than silently everything.

A name that is not attached raises crusoe_core.UnknownMCPServer, naming what is attached, rather than quietly attaching nothing:

MCP server(s) not attached to this project: wether-tools. Attached: doc-search-mcp, weather-tools

That is deliberate. An agent that was given tools and silently starts without them looks healthy, answers from the model alone, and is discovered by somebody reading answers rather than logs. The mistake is almost always a spelling, so the error prints the list. crusoe_core.mcp_server_names() returns that same list if you want to inspect it in code.

Attaching several servers at once is ordinary: each becomes its own toolset, and one turn can call a tool from each. Tool names are not namespaced by server, though, so two servers publishing the same tool name collide — worth avoiding when you name the tools, not when you attach them.

Attaching Servers to Platform Agents

The platform agent harness automatically reads the MCP_SERVERS environment variable at startup. Writing agent code against parse_mcp_servers() ensures that tools published by attached MCP servers are seamlessly attached to your agent runtime.

Call it from an external MCP client

The server speaks stateless streamable HTTP. Stateless means the server remembers nothing between requests, so every request has to stand on its own. That has one very convenient consequence: there is no initialize handshake — no opening exchange a client must complete before it is allowed to ask for anything.

A client can therefore send tools/list or tools/call as its very first request. That is what makes it safe to run several copies at once — any copy can answer any request.

List the tools:

curl -s -X POST "https://mcp-weather-tools-ab12cd.apps.codyhill.dev/mcp" \
-H "Authorization: Bearer $CAI_MCP_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

You should see (trimmed):

{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"get_forecast","description":"Current weather for a city.","inputSchema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]}}

The name, description, and input schema all come from the Python function you published — its signature and its docstring.

Call one:

curl -s -X POST "https://mcp-weather-tools-ab12cd.apps.codyhill.dev/mcp" \
-H "Authorization: Bearer $CAI_MCP_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"get_forecast","arguments":{"city":"Reykjavik"}}}'

The tool's return value comes back inside the JSON-RPC result. Any project secrets the tool declared as credential keys are fetched during this call and dropped when it returns — see manage secrets.

Do not send MCP-Protocol-Version: 2026-07-28

The MCP library this server is built on does not recognize the 2026-07-28 protocol label and rejects it with 400. Either omit the MCP-Protocol-Version header entirely — the server then assumes a version it supports — or send one of the versions the library supports. Some older material, including the console's own copyable snippet, still shows 2026-07-28; that is stale. For anything beyond a quick curl, use a real MCP client library, which negotiates the version for you.

Check that a server is alive

GET /healthz is served without a token, so a health probe does not need the bearer:

curl -s "https://mcp-weather-tools-ab12cd.apps.codyhill.dev/healthz"

You should see:

{"status":"ok"}

This is the one path on the server that does not authenticate. It reports only that the process is up — it does not run your tools.

Timeouts

  • Request timeout: 300 seconds. The platform cuts off every MCP request at 5 minutes. It tells the server that same number, so a tool can size its own work to fit. A tool that runs longer is cut off.
  • There is no cold start. An MCP server always keeps at least one instance running — agents are routed straight to it, and nothing wakes a stopped server, so scaling to zero would mean refused calls rather than slow ones. A first call is no slower than any other. The ADK helper's 120-second default is a per-call budget for a slow tool, not a wait for a machine to start. If a first call is slow or fails, check the address (/mcp, not the bare URL) and the bearer.
  • Servers are stateless. Nothing is kept between requests. If a tool needs to remember something, return it to the caller or write it somewhere that lasts.

Errors you will actually see

These come from the server itself, not the platform API. So they do not use the platform's usual error plus request_id error shape.

StatusBodyWhat it meansFix
401{"error":"unauthorized","detail":"missing or invalid bearer token"}No Authorization header, or the token does not match. Also sends WWW-Authenticate: Bearer.Send Authorization: Bearer with the server's own token. Another server's token will not work.
503{"error":"mcp auth not configured","detail":"CAI_MCP_AUTH_TOKEN is unset"}The server has no token configured, so it is refusing everyone. This is the fail-closed behavior working.The server did not get its credential at deploy. Publish a tool to redeploy it, and tell your administrator if it persists.
400(from the MCP library)Usually an unrecognized MCP-Protocol-Version header.Omit the header, or send a supported version.

Requests to the platform API about the server — listing it, publishing tools, rolling back — use the normal error shape and the normal rules. That includes the 404-not-403 rule: a project you hold no role on answers 404 not found, never 403 forbidden, so you cannot use error codes to discover which projects exist. See API authentication.

Next steps