--- 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: ```bash 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. 1. **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. 2. **Do NOT send the headers the upload endpoint hands you.** `POST /uploads/url` returns `upload.headers = {"x-amz-checksum-sha256": ""}`. Sending that header fails with: ``` AccessDenied: There were headers present in the request which were not signed HeadersNotSigned: x-amz-checksum-sha256 ``` The presigned URL signs only `content-length;host`, and the checksum is already a signed query parameter. `PUT` the body with **no extra headers**. (This is the single most likely reason an upload 403s.) 3. **Checksum format differs by call.** `POST /uploads/url` wants **hex** (`^[0-9a-fA-F]{64}$`). Anything else fails validation with an explicit regex error, so at least it tells you. 4. **A 404 on `POST /projects` is 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: ```bash curl -s -H "Authorization: Bearer $HORIZON_API_KEY" \ "$API/me/organizations/$ORG/permissions" | jq ``` You need `role: admin` (or another role carrying `write:projects`). A read-only key returns only `read:*`. 5. **Exclude `.env` from the archive.** Nothing stops you uploading secrets. Use project secrets instead (below), and assert the exclusion rather than trusting your tar flags. 6. **`GET /me` returns 401 for API keys** — it is a user-session endpoint. Use `GET /me/organizations`, which works fine. Don't take the 401 as a bad key. ## Do it ```bash set -euo pipefail API=https://horizon.prefect.io/api/v0 AUTH="Authorization: Bearer $HORIZON_API_KEY" SLUG=my-server # becomes .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://.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: ```bash 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. ```python import asyncio, os from fastmcp import Client async def main(): async with Client("https://.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.