MCP Documentation
Overview
What the MCP server is, where it lives, how it authenticates, and everything it can do.
What is MCP
The Model Context Protocol lets an AI assistant call real tools instead of guessing. Point a client at the Static.app MCP server and your assistant can list your sites, edit files, deploy a ZIP, and read form entries — in plain language, with your own API key.
Everything the server does runs through the Static.app REST API, so an MCP action and an API call have identical effects and permissions.
Endpoint
One hosted server, SSE transport, JSON-RPC 2.0 — per the MCP specification (2025-03-26).
| Endpoint | Method | Purpose |
|---|---|---|
/sse | GET | Opens the event stream and hands back a session message URL. |
/message?sessionId={id} | POST | Sends JSON-RPC requests into an open session. |
/upload | POST / PUT | Stages a ZIP out of band. See Uploading. |
Authentication
The server is multi-tenant: it holds no key of its own, so every client brings its own. Create one in Account → API — keys start with sk_. Three ways to pass it, in the order the server checks them:
| Method | Value | Notes |
|---|---|---|
| Authorization header | Authorization: Bearer sk_xxxx | Recommended. |
| Custom header | X-API-Key: sk_xxxx | For clients that cannot set Authorization. |
| Query parameter | /sse?api_key=sk_xxxx | Ends up in logs and browser history. Local development only. |
All tools
16 tools across three groups. Every one of them runs on our servers — none of them can see files on your machine, so content travels as arguments or through the upload endpoint.
| Tool | Group | What it does |
|---|---|---|
list_sites | Sites | List every site on the account. |
get_site | Sites | Get one site by PID. |
get_site_files | Sites | List a site's files. |
create_site_from_archive | Sites | Create a site from a ZIP. |
update_site_from_archive | Sites | Replace a site's contents from a ZIP. |
download_site_archive | Sites | Get a download link for the whole site. |
download_site_files | Sites | Get a download link for selected files. |
delete_site | Sites | Delete a site. |
write_site_files | Files | Create or replace files from inline content. |
read_site_file | Files | Read one file. |
delete_site_files | Files | Delete files or directories. |
create_upload | Uploading | Reserve a one-time upload slot for a ZIP. |
list_forms | Forms | List a site's forms. |
get_form | Forms | Get one form. |
get_form_entries | Forms | List submissions. |
delete_form_entry | Forms | Delete a submission. |
Site identifier (pid)
Every tool that touches a site takes its public 10-character identifier, e.g. 0aw4jtby1z — never the numeric database id. Run list_sites to see the PIDs on your account.
Setup
Connect a client in a couple of minutes. Every config below points at the same endpoint and differs only in how the client wants it written.
Claude Code
One command, run inside your project:
claude mcp add --transport sse static-app https://mcp.static.app/sse \
--header "Authorization: Bearer sk_xxxx"
Or commit a .mcp.json to the project and keep the key in the environment:
{
"mcpServers": {
"static-app": {
"command": "npx",
"args": [
"-y", "mcp-remote",
"https://mcp.static.app/sse",
"--header", "Authorization: Bearer ${STATIC_API_KEY}"
],
"env": { "STATIC_API_KEY": "sk_xxxx" }
}
}
}
Claude Desktop
Claude Desktop speaks stdio, so mcp-remote bridges it to the hosted endpoint. Open Settings → Developer → Edit Config and add:
{
"mcpServers": {
"static-app": {
"command": "npx",
"args": [
"-y", "mcp-remote",
"https://mcp.static.app/sse",
"--header", "Authorization: Bearer ${STATIC_API_KEY}"
],
"env": { "STATIC_API_KEY": "sk_xxxx" }
}
}
}
Restart Claude Desktop to pick up the change.
Cursor
Open Settings → MCP Servers → Add Server:
- Name:
static-app - Type:
sse - URL:
https://mcp.static.app/sse - Headers:
Authorization: Bearer sk_xxxx
Windsurf
Open Windsurf settings and add an MCP server with the same four values:
- Name:
static-app - Transport:
sse - URL:
https://mcp.static.app/sse - Headers:
Authorization: Bearer sk_xxxx
n8n & custom clients
Any client that speaks MCP over SSE works. Point it at https://mcp.static.app/sse with the Authorization header, and it will discover all 16 tools on connect.
Building your own? The handshake is two calls: open the stream, then post JSON-RPC to the session URL the stream hands back in its first endpoint event.
curl -N https://mcp.static.app/sse \
-H "Authorization: Bearer sk_xxxx"
# event: endpoint
# data: https://mcp.static.app/message?sessionId=f5df9141-...
Verify the connection
Ask the assistant to list your sites. If tools are wired up correctly it calls list_sites and answers with real domains. To check by hand, post into the session URL from above:
curl -X POST "https://mcp.static.app/message?sessionId=YOUR_SESSION_ID" \
-H "Authorization: Bearer sk_xxxx" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": "list_sites", "arguments": {} }
}'
The result arrives on the SSE stream, not in the POST response — that call returns an empty acknowledgement.
Site tools
List, inspect, deploy, and delete sites. Every one of these takes the site's pid, not its numeric id.
list_sites
Returns every site in the active workspace.
Arguments
| Name | Type | Required | Description |
|---|---|---|---|
| No arguments. | |||
Example result
[
{
"pid": "xxxxxxxxxx",
"slug": "example-site",
"name": "Example Site",
"domain": "example.static.app",
"status": true,
"full_url": "https://example.static.app",
"created_at": "2025-01-15T10:00:00Z",
"updated_at": "2025-02-01T12:30:00Z"
}
]
Ask your assistant
"Show me all my Static.app sites."
get_site
Returns a single site.
Arguments
| Name | Type | Required | Description |
|---|---|---|---|
pid | string | required | Public identifier, e.g. 0aw4jtby1z. |
get_site_files
Lists a site's files and directories, recursively from the root.
Arguments
| Name | Type | Required | Description |
|---|---|---|---|
pid | string | required | Public identifier of the site. |
Example result
{
"status": "success",
"current_path": "",
"files": [
{ "name": "index.html", "path": "index.html", "is_dir": false, "size": 1234, "type": "html", "hash": "0123456789abcdef0123456789abcdef" },
{ "name": "images", "path": "images", "is_dir": true, "size": null, "type": "directory", "hash": null }
]
}
create_site_from_archive
Creates a new site from a ZIP archive. Give exactly one archive source — they are tried in the order below. The server cannot read files from your machine, so the archive arrives either through create_upload or from a public URL.
Arguments
| Name | Type | Required | Description |
|---|---|---|---|
upload_id | string | optional | Preferred. Id from create_upload once the ZIP has been PUT to its URL. |
archive_url | string | optional | URL to download the ZIP from. |
archive_base64 | string | optional | Legacy fallback. See Legacy base64. |
name | string | optional | Desired subdomain, e.g. my-site. A random one is generated otherwise. |
Example result
{
"status": "success",
"pid": "xxxxxxxxxx",
"slug": "my-site",
"url": "https://my-site.static.app"
}
update_site_from_archive
Replaces a site's contents with a new ZIP. To change a handful of files instead of the whole site, use write_site_files.
Arguments
| Name | Type | Required | Description |
|---|---|---|---|
pid | string | required | Site to update. |
upload_id | string | optional | Preferred. Id from create_upload. |
archive_url | string | optional | URL to download the ZIP from. |
archive_base64 | string | optional | Legacy fallback. |
download_site_archive
Packs the whole site and returns a download link.
Arguments
| Name | Type | Required | Description |
|---|---|---|---|
pid | string | required | Site to download. |
Example result
{
"status": "success",
"url": "https://static.app/storage/zip/abcdef0123456789_download.zip"
}
download_site_files
Packs selected files and returns a download link.
Arguments
| Name | Type | Required | Description |
|---|---|---|---|
pid | string | required | Site to download from. |
files | string | required | JSON array of paths, e.g. ["index.html", "css/style.css"]. |
delete_site
Deletes a site and everything in it. There is no undo.
Arguments
| Name | Type | Required | Description |
|---|---|---|---|
pid | string | required | Site to delete. |
File tools
Read, write, and delete individual files without redeploying the whole site.
write_site_files
Creates or replaces files by sending their contents inline. Files that do not exist yet are created, and missing directories are created along the way. Nothing needs to be on disk anywhere, so this is the tool that works against the hosted server.
Arguments
| Name | Type | Required | Description |
|---|---|---|---|
pid | string | required | Site to write to. |
files | string | required | JSON array of {path, content, encoding}. Up to 200 entries. |
create_only | boolean | optional | Fail with 409 rather than overwrite an existing file. Defaults to false. |
File entry fields
| Field | Type | Required | Description |
|---|---|---|---|
path | string | required | Relative to the site root, e.g. css/style.css. Paths that walk outside the site are rejected. |
content | string | required | The file's contents. Max 200MB. |
encoding | string | optional | "text" (default) sends the content verbatim; "base64" for images and other binaries. |
Example arguments
{
"pid": "xxxxxxxxxx",
"files": "[{\"path\": \"index.html\", \"content\": \"<!doctype html><h1>Hello</h1>\"}, {\"path\": \"img/logo.png\", \"content\": \"iVBORw0KGgo...\", \"encoding\": \"base64\"}]"
}
Example result
{
"status": "success",
"written": 2,
"files": [
{ "name": "index.html", "path": "index.html", "size": 34, "hash": "0123456789abcdef0123456789abcdef" },
{ "name": "logo.png", "path": "img/logo.png", "size": 2048, "hash": "89abcdef0123456789abcdef01234567" }
],
"errors": []
}
base64 only for binaries.Errors
| Code | When |
|---|---|
| 409 | create_only was set and every file already exists. |
| 422 | Nothing could be written — unsupported extension, invalid path, or bad base64. |
Ask your assistant
"Add a privacy.html page to site xxxxxxxxxx with a short placeholder policy."
"Change the heading colour in css/style.css on site xxxxxxxxxx to #6551E0."
read_site_file
Returns one file's contents. Text comes back verbatim; binaries come back base64-encoded with encoding set accordingly.
Arguments
| Name | Type | Required | Description |
|---|---|---|---|
pid | string | required | Site to read from. |
path | string | required | Relative to the site root, e.g. css/style.css. |
Example result
{
"status": "success",
"path": "css/style.css",
"encoding": "text",
"size": 14,
"hash": "fedcba9876543210fedcba9876543210",
"content": "body{margin:0}"
}
Errors
| Code | When |
|---|---|
| 400 | path is missing or points outside the site. |
| 404 | Site or file not found. |
| 413 | File is larger than 1MB — use download_site_files instead. |
delete_site_files
Removes files or directories. Directories go recursively.
Arguments
| Name | Type | Required | Description |
|---|---|---|---|
pid | string | required | Site to delete from. |
paths | string | required | JSON array of paths, e.g. ["old.html", "assets/legacy"]. |
A note on local paths
The hosted server runs on our infrastructure and shares no filesystem with you, so there is no tool that takes a path on your machine. Anything that has to travel does so as content or over HTTP:
| You want to | Use |
|---|---|
| Change or add a file | write_site_files — send the contents |
| Deploy a ZIP you have locally | create_upload, then upload_id |
| Deploy a ZIP already online | archive_url |
Self-hosting the server yourself? Set LOCAL_FILE_ACCESS=true and it additionally exposes upload_site_files plus an archive_path argument, which read from the machine the server runs on.
Uploading
How to get content into a site without paying for it twice — once in bandwidth, once in tokens.
Which method to use
The server runs remotely, so nothing reads from your disk. Two questions decide the rest: are you changing a few files or replacing the whole site, and does your client have a shell?
| Situation | Use | Cost in tokens |
|---|---|---|
| Editing pages, styles, adding a file | write_site_files | The file's own text, nothing more. |
| Replacing a whole site from a ZIP | create_upload + upload_id | None — the archive never enters the conversation. |
| The ZIP is already hosted somewhere | archive_url | None. |
| Client has no shell and no hosting | archive_base64 | Roughly 1.4 tokens per byte of ZIP. |
create_upload
Reserves a one-time slot and hands back a URL to PUT the archive to, plus a ready-to-run curl command. The file goes straight from your disk to the server, so its size costs nothing in context.
Arguments
| Name | Type | Required | Description |
|---|---|---|---|
file_name | string | optional | Name for the archive. Defaults to archive.zip. |
Example result
{
"upload_id": "0e9837a760c5062279ea57107d8f0a1c",
"upload_url": "https://mcp.static.app/upload/0e9837a760c5062279ea57107d8f0a1c",
"file_name": "site.zip",
"expires_at": "2026-08-25T12:57:32Z",
"max_size": 204800000,
"single_use": true
}
Then PUT the archive
curl -X PUT --data-binary @site.zip \
-H "Authorization: Bearer sk_xxxx" \
https://mcp.static.app/upload/0e9837a760c5062279ea57107d8f0a1c
Then deploy it
{
"pid": "xxxxxxxxxx",
"upload_id": "0e9837a760c5062279ea57107d8f0a1c"
}
Upload endpoint
The tool is a convenience — any HTTP client can drive the endpoint directly. POST stages a file and returns a fresh id in one step; PUT fills an id that create_upload handed out.
Example request
curl -X POST --data-binary @site.zip \
-H "Authorization: Bearer sk_xxxx" \
"https://mcp.static.app/upload?file_name=site.zip"
Example response
{
"status": "success",
"upload_id": "0e9837a760c5062279ea57107d8f0a1c",
"file_name": "site.zip",
"size": 5000000,
"sha256": "1df9cfae4840f7c4eef12dbd4691c043ce4e247130817053c55f1aa04eddd971",
"expires_at": "2026-08-25T12:57:32Z"
}
Errors
| Code | When |
|---|---|
| 401 | No API key on the request. |
| 403 | The id belongs to a different API key. |
| 404 | Unknown or expired id. |
| 409 | Content was already uploaded for this id. |
| 413 | Body is over the 200MB ceiling. |
| 503 | Staging is disabled on the server — use archive_url instead. |
Legacy base64
archive_base64 still works and is checked last. It exists for clients that cannot make an HTTP request of their own — but base64 adds a third on top of the raw bytes, and every one of them passes through the model, so a 2MB archive costs roughly 700k tokens. Prefer upload_id whenever a shell is available.
# macOS
base64 -i site.zip | tr -d '\n'
# Linux
base64 -w 0 site.zip
Form tools
Read the forms on a site and work through their submissions.
list_forms
Returns every form on a site.
Arguments
| Name | Type | Required | Description |
|---|---|---|---|
pid | string | required | Public identifier of the site. |
get_form
Returns a single form with its fields.
Arguments
| Name | Type | Required | Description |
|---|---|---|---|
pid | string | required | Public identifier of the site. |
form_id | string | required | Id of the form. |
get_form_entries
Returns submissions for a form, newest first.
Arguments
| Name | Type | Required | Description |
|---|---|---|---|
pid | string | required | Public identifier of the site. |
form_id | string | required | Id of the form. |
limit | number | optional | How many entries to return. |
offset | number | optional | Offset for pagination. |
Ask your assistant
"Summarise the last 20 contact form submissions on site xxxxxxxxxx."
delete_form_entry
Deletes one submission.
Arguments
| Name | Type | Required | Description |
|---|---|---|---|
pid | string | required | Public identifier of the site. |
form_id | string | required | Id of the form. |
entry_id | string | required | Id of the entry to delete. |
Troubleshooting
The failures that actually come up, and what each one means.
401 Unauthorized
- Check the key is current and has not been revoked in Account → API.
- The header needs the
Bearerprefix:Authorization: Bearer sk_xxxx. - In a config file, confirm the environment variable actually resolved — a literal
${STATIC_API_KEY}reaching the server reads as a bad key. - The MCP server is on paid plans. On a free plan the key authenticates but the account has no access.
"Site not found"
- Pass the PID — the 10-character public id like
0aw4jtby1z— not the numeric database id. - The site has to belong to the account behind the key. Run
list_sitesto see which ones do. - Switching workspaces changes which sites are visible.
Passing a path from your machine
The hosted server shares no filesystem with you, so a path like /Users/me/site.zip means nothing to it. An older client that still sends archive_path gets an error explaining what to use instead; one that calls upload_site_files gets tool not found, since that tool is not registered here at all.
- Changing files →
write_site_files, with the contents inline. - Deploying an archive →
create_upload, PUT the ZIP, thenupload_id. - Archive already hosted →
archive_url. - Running the server yourself →
LOCAL_FILE_ACCESS=truebrings the local-path tools back.
Tools not showing up
- Restart the client after editing its MCP config — most read it only at startup.
- Claude Desktop logs the handshake:
~/Library/Logs/Claude/mcp*.logon macOS. Cannot find module 'mcp-remote'— usenpx -y mcp-remote, which installs it on the spot.- A healthy connection lists 16 tools. Fewer means the client cached an older session.
Connection errors
- The URL is exactly
https://mcp.static.app/sse. - SSE holds one long-lived connection. Proxies that buffer responses or cut idle connections break it.
- Test the endpoint on its own:
curl -N https://mcp.static.app/sse -H "Authorization: Bearer sk_xxxx". It should print anevent: endpointline straight away.
Upload rejected
| Code | Meaning | Fix |
|---|---|---|
| 403 | The id belongs to another key. | Use the same key that called create_upload. |
| 404 | Unknown or expired id. | Slots live 30 minutes and are deleted once deployed. Reserve a new one. |
| 409 | Content already uploaded for this id. | A slot takes one file. Reserve another to re-upload. |
| 413 | Over the 200MB ceiling. | Split the deployment, or drop large assets and add them with write_site_files. |
| 503 | Staging is switched off on the server. | Deploy with archive_url, or ask us to point UPLOAD_DIR at a writable path. |
| 422 | Nothing could be written. | Check the errors array — usually an unsupported extension or a path leaving the site root. |
Still stuck
The same operations are available over HTTP — reproducing a failure against the REST API tells you whether the problem is the client or the account. If it is neither, contact support.