README
¶
firstboot-mcp
The Model Context Protocol server for the Firstboot cloud platform. It gives a model sixteen tools over one account.
{
"mcpServers": {
"firstboot": {
"command": "firstboot-mcp",
"env": {
"FIRSTBOOT_API_URL": "https://api.example.com",
"FIRSTBOOT_TOKEN": "pat_..."
}
}
}
}
It speaks over stdin and stdout, which is what an MCP client launches it for.
go install github.com/firstboot-io/mcp/cmd/firstboot-mcp@latest
The command lives under cmd/firstboot-mcp so that line produces a binary by
that name. A main at the repository root would install as mcp, which is both
wrong on a PATH and not what the configuration above launches.
The token is the security decision
The server authenticates with one API token and can do nothing that token cannot. The boundary is enforced by the API, not by this process, so a bug here cannot widen it.
Create the token in the panel under Account settings → API tokens and give it the narrowest scopes the work needs:
| Give it | To let the model |
|---|---|
| read only | answer questions, read logs, check balance and quotas |
| + deploy | deploy apps, roll back, restart servers |
| + write | create servers, resize, edit DNS and firewall rules |
| + destroy | delete servers |
Without destroy, delete_server simply fails. For a server whose job is
answering questions about an account, that is the right outcome rather than a
limitation.
Three classes of thing are closed to every token whatever its scopes, and they are the three that matter most with a model in the loop:
- Credentials on the wire. An app's environment variables and a database's connection details. Neither can reach a chat transcript through this server, because neither can reach this server.
- Interactive root. A container shell and a VM console. A live byte stream cannot be bounded by a scope the way a request can.
- Personal data. A domain's registrant profile is a real person's home address and phone number.
The tools
Sixteen, named after jobs rather than endpoints. That is a budget: every tool's schema is sent to the model on every request, so a server that wrapped the API's ~190 operations one-to-one would spend the context window before the conversation started, and leave the model choosing between near-identical names.
| Tool | Scope | Notes |
|---|---|---|
list_resources |
read | one tool, a kind argument: servers, apps, databases, volumes, networks, load balancers, floating IPs, DNS zones, domains. A tags argument narrows to what carries every tag listed |
get_resource |
read | the same kinds, one resource in full |
get_catalog |
read | plans, regions and images in one answer, showing which plans each region actually sells |
account_limits |
read | quotas with what is left, and which products are switched on |
wallet_status |
read | balance, hourly burn, runway |
app_status |
read | state, replicas, releases and recent builds in one answer |
app_logs |
read | asks the node and waits for the answer, which is two API calls |
search_docs |
read | the platform's own documentation |
create_server |
write | waits until it is running, returns the address; takes tags so the machine is labelled at birth |
power_server |
deploy | start, stop, reboot, force_stop; waits for the action |
resize_server |
write | waits for the machine to come back |
delete_server |
destroy | refuses unless the server's name is repeated back |
deploy_app |
deploy | builds AND waits, then says whether it worked |
rollback_app |
deploy | puts an earlier release back |
set_dns_record |
write | upsert: creates or updates, so "point www here" is one call |
set_firewall_rules |
write | replaces the whole set, which is what the API offers |
Tags arrived on 2026-08-27 and cost no tool, which is the rule working: a
list_tagged_resources would have been a seventeenth schema in every request's
context to say something the model can already say as an argument. The count is
held by a test.
Annotations are derived, not typed
readOnlyHint and destructiveHint are what a client uses to decide whether to
stop and ask the human. A tool that deletes a server while claiming to be
read-only walks straight past that check, which is why a mislabelled tool is a
vulnerability class rather than a cosmetic slip.
So nothing here is a hand-written claim about danger. Each tool declares the API
operations it calls; the platform publishes what scope each operation costs
(x-firstboot-scope, stamped from the same table its middleware consults); the
tool's scope is the strongest of those; and the annotations are computed from
the scope. The chain is:
what the tool calls → what the API says that costs → what this server promises
internal/tools/spec_test.go reads the platform's own openapi.json and breaks
the build if any link fails, naming the operation. A tool may be more
conservative than its scope implies and never less: resize_server is
write-scoped and marked destructive because it restarts the machine.
A tool that calls an operation closed to API tokens fails the same test. That is how "the model cannot read your app's secrets" stays true as the tools grow.
Retrying a create cannot buy a second server
The SDK mints an Idempotency-Key per request and reuses it across its own
retries, which protects the request. It does not protect the conversation,
and the conversation is what this server sits in: a model that calls
create_server, loses the answer to a timeout and calls it again has made two
logical requests, and the account gets two machines and two months of billing.
So the key is derived from the arguments. Two calls asking for exactly the
same thing carry the same key and the API answers the second with the first
one's resource. That is what lets create_server honestly advertise
idempotentHint.
The cost is that two genuinely separate requests for an identical resource collapse into one for 24 hours. The arguments include the name, so "another server like that one" differs in the field that has to differ anyway, and two calls with the same name are exactly the case that should collapse.
What it deliberately does not do
- No credential reads, no shells, no consoles, no registrant contacts. Not because this server declines: because a token cannot reach them.
- No DNS record deletion. Removing DNS is how a site goes dark, and it is not something to do from a chat turn. The panel has it.
- No password reset.
serverPasswordResetreturns the new root password in its response body, which for an MCP client means the password lands in a chat transcript. It is not exposed as a tool. - No topping up, no promo codes, no billing changes. Those are closed to tokens anyway.
Configuration
| Variable | Meaning |
|---|---|
FIRSTBOOT_API_URL |
the API's base URL |
FIRSTBOOT_TOKEN |
the API token (pat_…) |
FIRSTBOOT_DOCS_URL |
the documentation site search_docs reads; a self-hosted deployment serves its own |
--docs-url overrides the last one. --version prints the version and exits.
Development
go build ./...
go test ./...
The SDK is consumed as a released module (github.com/firstboot-io/go-sdk),
not as a sibling checkout: the replace directive is gone. While that
repository is private the module proxy cannot serve it, so a build needs
go env -w GOPRIVATE='github.com/firstboot-io/*'
git config --global url."git@github.com:firstboot-io/".insteadOf "https://github.com/firstboot-io/"
once, and nothing after the repositories go public.
The scope tests read ../platform/api/openapi/openapi.json. Without the
platform checked out beside this repository those tests SKIP, which is why CI
checks it out and then fails if the skip happened: a passing suite that checked
nothing is the outcome this package exists to avoid.
Requirements
Go 1.25 or newer.
License
Apache License 2.0. See LICENSE.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
firstboot-mcp
command
Command firstboot-mcp is the Model Context Protocol server for the Firstboot cloud platform.
|
Command firstboot-mcp is the Model Context Protocol server for the Firstboot cloud platform. |
|
internal
|
|
|
tools
Package tools is the tool surface this server exposes to a model.
|
Package tools is the tool surface this server exposes to a model. |