~/.claude/skills/horizon-deploy
name: horizon-deploy description: Deploy a FastMCP server to Horizon from a local directory with no git repo, using the source-upload path. Use when someone wants to try a tool idea without creating a repo, or asks how to deploy to Horizon/FastMCP Cloud without GitHub. #
Deploying to Horizon without git #
Horizon can build a hosted MCP server from an uploaded source archive. No repo, no GitHub connection, no CI. This is the fastest path from "I have a tool idea" to a URL, and it is the right answer to "do I need a whole repo for one tool?" — you don't.
You do not need the Horizon source to do this. The API fully self-documents:
curl -s https://horizon.prefect.io/api/v0/openapi.json | jq '.paths | keys'
That is unauthenticated. Read it first when anything below looks stale — the spec is the truth, this file is a summary of it.
The shape #
resolve org -> create project -> get presigned url -> PUT archive
-> create version (builds) -> poll -> create deployment
Base URL is https://horizon.prefect.io/api/v0. Auth is Authorization: Bearer $HORIZON_API_KEY on every call.
Gotchas that will cost you an hour #
These are the things the spec does not tell you, discovered by doing it.
-
Upload the archive BEFORE creating the version. Creating a version kicks off a build immediately. If the object is not in S3 yet, it builds against nothing.
-
Do NOT send the headers the upload endpoint hands you.
POST /uploads/urlreturnsupload.headers = {"x-amz-checksum-sha256": "<base64>"}. Sending that header fails with:AccessDenied: There were headers present in the request which were not signed HeadersNotSigned: x-amz-checksum-sha256The presigned URL signs only
content-length;host, and the checksum is already a signed query parameter.PUTthe body with no extra headers. (This is the single most likely reason an upload 403s.) -
Checksum format differs by call.
POST /uploads/urlwants hex (^[0-9a-fA-F]{64}$). Anything else fails validation with an explicit regex error, so at least it tells you. -
A 404 on
POST /projectsis a permissions problem, not a wrong URL. Writes are masked as 404 when the key lacks scope, which looks exactly like a bad path and sends you hunting through the spec for nothing. Check first:curl -s -H "Authorization: Bearer $HORIZON_API_KEY" \ "$API/me/organizations/$ORG/permissions" | jqYou need
role: admin(or another role carryingwrite:projects). A read-only key returns onlyread:*. -
Exclude
.envfrom the archive. Nothing stops you uploading secrets. Use project secrets instead (below), and assert the exclusion rather than trusting your tar flags. -
GET /mereturns 401 for API keys — it is a user-session endpoint. UseGET /me/organizations, which works fine. Don't take the 401 as a bad key.
Do it #
set -euo pipefail
API=https://horizon.prefect.io/api/v0
AUTH="Authorization: Bearer $HORIZON_API_KEY"
SLUG=my-server # becomes <slug>.fastmcp.app
ENTRYPOINT=src/my_server/server.py:mcp # file:object
DEPS=pyproject.toml
ORG=$(curl -s -H "$AUTH" "$API/me/organizations" | jq -r '.items[0].id')
PID=$(curl -s -X POST "$API/organizations/$ORG/projects" -H "$AUTH" \
-H 'Content-Type: application/json' \
-d "{\"kind\":\"hosted\",\"slug\":\"$SLUG\",\"name\":\"$SLUG\"}" | jq -r .id)
# archive WITHOUT secrets or local junk, then prove it
TAR=$(mktemp -t src).tar.gz
tar --exclude='.env' --exclude='.env.*' --exclude='.venv' --exclude='.git' \
--exclude='__pycache__' --exclude='dist' --exclude='*.egg-info' \
-czf "$TAR" .
tar -tzf "$TAR" | grep -qE '(^|/)\.env' && { echo "refusing: .env in archive"; exit 1; }
SIZE=$(wc -c < "$TAR" | tr -d ' ')
SHA=$(shasum -a 256 "$TAR" | cut -d' ' -f1) # hex
URL=$(curl -s -X POST "$API/organizations/$ORG/projects/$PID/uploads/url" -H "$AUTH" \
-H 'Content-Type: application/json' \
-d "{\"sizeBytes\":$SIZE,\"checksumSha256\":\"$SHA\"}" | jq -r .upload.url)
# no -H here. see gotcha 2.
curl -s --fail -X PUT "$URL" --data-binary @"$TAR"
VID=$(curl -s -X POST "$API/organizations/$ORG/projects/$PID/versions" -H "$AUTH" \
-H 'Content-Type: application/json' \
-d "{\"source\":{\"kind\":\"archive\",\"checksumSha256\":\"$SHA\"},
\"build\":{\"entrypoint\":\"$ENTRYPOINT\",\"dependencyPath\":\"$DEPS\"}}" | jq -r .id)
until [ "$(curl -s -H "$AUTH" "$API/organizations/$ORG/projects/$PID/versions/$VID" | jq -r .status)" != "building" ]; do
sleep 10
done
curl -s -H "$AUTH" "$API/organizations/$ORG/projects/$PID/versions/$VID" | jq '{status, error, tools: ((.manifest.tools // []) | map(.name))}'
TID=$(curl -s -H "$AUTH" "$API/organizations/$ORG/projects/$PID/targets" | jq -r '.items[0].id')
curl -s -X POST "$API/organizations/$ORG/projects/$PID/deployments" -H "$AUTH" \
-H 'Content-Type: application/json' \
-d "{\"targetId\":\"$TID\",\"versionId\":\"$VID\"}" | jq '{id, status}'
The server lands at https://<slug>.fastmcp.app/mcp.
Secrets #
Anything the server needs at runtime (API keys for whatever it wraps) goes in as a project secret, one call per key:
curl -s -X POST \
"$API/organizations/$ORG/projects/$PID/environments/production/secrets" \
-H "$AUTH" -H 'Content-Type: application/json' \
-d '{"keyName":"SOME_API_KEY","value":"...","sensitive":true}'
environment is production or preview. Set these before deploying so the first
rollout has them.
Verify #
The deployed server requires a bearer token by default — it is not open to the internet, which matters if it wraps credentials.
import asyncio, os
from fastmcp import Client
async def main():
async with Client("https://<slug>.fastmcp.app/mcp",
auth=os.environ["HORIZON_API_KEY"]) as c:
print([t.name for t in await c.list_tools()])
asyncio.run(main())
Updating #
Same flow minus project creation: new archive → new presigned URL → PUT → new version → new deployment. Keep the project id around and it is four calls.
When someone asks for a "scratch" or "junk drawer" server #
They are describing this flow. One hosted project can hold as many tools as you
like — a single server.py with ten @mcp.tool functions is a perfectly good junk
drawer, and it upgrades in one place. The friction being described is usually not
"I need a new kind of server", it is "I did not know I could deploy without a
repo." Point them here first.