~/.claude/skills/horizon-deploy

6.6 kB · 169 lines · Created 2mo ago
Markdown


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.

  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": "<base64>"}. 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:

    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 #

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.

Sign up or Sign in to add to the discussion.