AI assistants (MCP)¶
upgradescope mcp runs a Model Context Protocol
server, so an AI assistant (Claude Code, Claude Desktop, any MCP client) can
ask "can I bump this cluster to 1.38?" or "which add-ons block the upgrade?"
and get the same structured report the CLI writes, instead of parsing
terminal output. Every tool is read-only: none changes a cluster, the fleet
server or a file.
The server speaks MCP on stdin and stdout, so a client starts it as a
subprocess; there is nothing to deploy. It is part of the one binary
(upgradescope mcp); it adds about 2 MiB to it (2.00 MiB on linux/amd64, stripped, measured
at 67f30be; MC-08 in the claims ledger has the command).
Connect an assistant¶
claude mcp add upgradescope -- upgradescope mcp --context staging
or commit a .mcp.json at the project root, which Claude Code offers to
enable:
{
"mcpServers": {
"upgradescope": {
"command": "upgradescope",
"args": ["mcp", "--context", "staging"]
}
}
}
Add the server to claude_desktop_config.json (macOS:
~/Library/Application Support/Claude/claude_desktop_config.json,
Windows: %APPDATA%\Claude\claude_desktop_config.json) and restart the
app:
{
"mcpServers": {
"upgradescope": {
"command": "upgradescope",
"args": ["mcp", "--kubeconfig", "/Users/me/.kube/config", "--context", "staging"]
}
}
}
A desktop app does not start with your shell's environment: give
command as a full path if upgradescope is not on its PATH, and name
the kubeconfig with --kubeconfig rather than relying on $KUBECONFIG.
Then ask: "Scan for an upgrade to 1.38 and list the blockers." The assistant
calls scan, then list_findings with severity: "blocker".
The cluster a scan reads¶
scan reads the cluster --kubeconfig and --context name, and otherwise
what upgradescope scan reads: $KUBECONFIG, then ~/.kube/config, at that
kubeconfig's current context. Without --context, the current context is
read once, when the server starts, and kept (the server prints it on
stderr), so kubectl config use-context afterwards, by you or by an
assistant's shell tool, does not move the server to another cluster. If
there is none to keep at start (no kubeconfig, one that names no current
context, or one that cannot be read), scan is off for the life of the
server: the start message says so, every scan call is refused saying to
restart with --context, and a context that becomes current later is never
used. The other tools work. That includes a pod with no kubeconfig:
upgradescope mcp does not fall back to the pod's service account as
upgradescope scan does, since no context names it and so none can be
pinned; mount a kubeconfig for the service account and pass --kubeconfig.
The server has no other default and takes no other input from a call: an
assistant chooses the target versions, never the cluster, a kubeconfig or
a directory of manifests, so it cannot point the
scanner at a credential file or an exec plugin of its choosing. No ignore
file (.upgradescope.yaml) is looked up, since the working directory is the
client's; suppressions written on the objects themselves (the
upgradescope.basit.engineer/ignore annotation) apply as they do in a scan.
A scan needs the same read access as upgradescope scan
(see Security model and RBAC),
reads the whole cluster and can take minutes. A call with several targets
reads the cluster once and judges it at each. Scans run one at a time; a
call the client cancels stops its scan, or stops waiting for another one.
A client that goes away cancels its calls too: on stdio by closing its
end, and over HTTP by closing the connection that carries the call (its
answer could not be delivered any more), so an abandoned scan gives up the
scan slot at once.
Tools¶
| Tool | What it does | Inputs |
|---|---|---|
scan |
Scans the cluster for each target minor and returns one report per target. The reports are kept for list_findings and get_report. |
targets (1 to 4 minors, e.g. 1.38) |
list_findings |
Lists a report's findings, filtered. | a source (below), severity (blocker, warning, info), category (e.g. eol-addon, removed-api), limit (default 50, at most 500) |
get_report |
Returns the whole report: score, verdict, findings, what was not assessed. | a source (below) |
registry_lookup |
Looks an add-on up in the registry compiled into the binary: end-of-life dates, release lines, Kubernetes compatibility. Needs no cluster. | query (id, name, Helm chart, image or node runtime), limit |
fleet_summary |
Fleet mode only: the fleet's score matrix, one row per cluster. | targets (optional) |
The source of list_findings and get_report is one of:
- nothing: the latest
scan(nametargetwhen it scanned several); report_file: a JSON report written byupgradescope scan --output json;inventory_filewithtarget: an inventory document (theinventoryof an agent's snapshot:schemaVersion1 withclusterIdandcapabilities), judged at that target; mostly for fixtures and tests. The file has no push envelope, so the check a server makes for an agent collected with another knowledge base (Running the server) cannot be made: it is judged with this binary's knowledge base as if collected with it, and one collected by an agent with older data can readreadyon evidence that agent never collected. Usecluster(fleet mode) for a verdict that includes that check;cluster(fleet mode): a cluster on the server, by name or id, attargetor its next minor.
A path must name a regular file you can read: a directory, a named pipe, a
device or /dev/stdin is refused at once, before it is opened for reading.
On Linux and macOS the file is also opened without waiting, so a named pipe
put in its place between the check and the open is refused too; Windows has
no such open, and the check is the only guard there. A regular file whose
read blocks (one on a stalled NFS or FUSE mount) has no deadline the
operating system honours: the call returns when the client cancels it, and
the read goes on in the background until the file answers. Such a read keeps
its place among the two file reads that run at once until it returns, so
cancelled calls cannot pile up reads behind a stalled mount; while two are
stuck, a further call waits, and gives up when the client cancels it.
A report may be at most 8 MiB and an inventory at most 20 MiB (what a
default server takes from an agent), whatever size the file reports or
grows to while it is read. An inventory is checked as a server checks an
agent's push: one the server would refuse (an identifier that is not valid
for what it names, a serverVersion that is not 1.x, a value over the
limits collectors keep to) is refused, and capability reasons over their
limit are cut and judged, as the server cuts them. A file that is not an
upgradescope report, one that does not follow api/report.schema.json, or
not an inventory the server would judge, is refused with the reason, not
scored; the reason names the field and the rule, and never quotes the file,
neither a value nor a key. A report is also refused when an object repeats a
member name, or has one that differs from a declared name only in case (a
FINDINGS beside findings): the schema leaves objects open, and the tools
read exactly the declared names, so they cannot disagree about one document.
So is a report with a minor (target, since, a hop's from or to) or an
annualCostDelta longer than 2 KiB, which the cut below would otherwise
leave outside the form the schema gives it.
Large reports¶
A result carries its document twice, as structuredContent and as the same
JSON in a text block, and an MCP client takes a bounded message: the Go SDK's
client at most 16 MiB. That is why a report is read only up to 8 MiB. A
result that would still be larger than a client takes (a get_report of a
report near that bound, a scan of a very large cluster) is refused with
the reason and what to ask for instead: list_findings with severity,
category or a smaller limit. The reports of such a scan are kept, so
list_findings reads them.
Holding a report while it is checked and sent takes several times its size,
so at most two get_report, list_findings or fleet_summary calls run at
once, however many a client sends in parallel; the others wait their turn,
or give up when the client cancels them.
Output is the published schema¶
get_report returns the document api/report.schema.json
describes, which is also what scan --output json and
GET /api/v1/clusters/{id}/report write. Its output schema, as tools/list
shows it, is that schema; the output schemas of scan and list_findings
refer to its definitions, so a finding is the same object everywhere. See
JSON report for the fields and the
versioning promise (schemaVersion 1; fields are only added). Every result
is returned as structuredContent and as text. Every result of every tool
(scan, get_report, list_findings, registry_lookup and
fleet_summary) has two text blocks: a notice about the cluster's text in
it (see below), then the JSON. A tool error has two
as well, the notice and then the error. So read the JSON from
structuredContent, or from the second text block, never the first.
Fleet mode¶
Give the server the address of an upgradescope server and its
read token and get_report and list_findings take cluster, and
fleet_summary appears:
{
"mcpServers": {
"upgradescope": {
"command": "upgradescope",
"args": ["mcp", "--server-url", "https://upgradescope.example.com",
"--read-token-file", "/Users/me/.config/upgradescope/read-token"]
}
}
}
The token is the server's --read-token, from --read-token-file,
$UPGRADESCOPE_READ_TOKEN or --read-token (visible in process listings:
avoid it). The server's own read authentication decides what is allowed, as
it does for curl. A server that requires a token rejects a call made
without one, and the tool returns that 401 as its error, saying to
supply the token. An open server (loopback, or --allow-anonymous-read)
needs none, and answers a token it does not know with 401.
The client follows no redirect (a 3xx is the tool's error), and a read
token sent over plain http:// to a host that is not loopback gets a
warning on stderr: it crosses the network in the clear.
Streamable HTTP¶
For a client that connects instead of starting a process:
upgradescope mcp --http 127.0.0.1:8808 # serves http://127.0.0.1:8808/mcp
A bare port or :PORT binds 127.0.0.1. Without a token the endpoint has
no authentication, and loopback is not a boundary between users: any
local user or process that can reach the port can call scan, which reads
your cluster with your kubeconfig. Give it a bearer token that every
request must carry:
openssl rand -hex 32 > ~/.config/upgradescope/mcp-token
upgradescope mcp --http 127.0.0.1:8808 --http-token-file ~/.config/upgradescope/mcp-token
claude mcp add --transport http upgradescope http://127.0.0.1:8808/mcp \
--header "Authorization: Bearer $(cat ~/.config/upgradescope/mcp-token)"
The token can also come from $UPGRADESCOPE_MCP_HTTP_TOKEN or --http-token
(visible in process listings). A request without it, or with another, gets
401, and its connection is closed without reading the body it sent.
Whatever the token, a peer cannot hold the server's connections or memory:
- a request's headers must arrive within 10 seconds and the whole request
within 60 seconds, its headers may take at most 64 KiB (
431past that) and its body at most 4 MiB (413); a keep-alive connection idle for 120 seconds is closed. There is no limit on writing an answer, so a scan that takes minutes still returns its result; - an MCP session that gets no request for 10 minutes is closed (a client
that comes back gets
404and opens a new one, as the MCP specification has it), and at most 100 sessions are open at once: past that a new one is refused with503and the reason, and the open ones go on working.
An address that is not loopback is refused unless --allow-remote is
given; use it with --http-token, or when something in front of the
endpoint (a mesh, a proxy that authenticates) is the access control, and
note the endpoint is plain HTTP, so the token crosses that network in the
clear. A request from another origin in a browser is refused. The MCP SDK
refuses a request whose Host header is not loopback (DNS rebinding) only
when it arrives on a loopback address: a request that arrives on the
address --allow-remote opens gets no such check.
What to keep in mind¶
- Almost every string a tool returns is text upgradescope did not write,
and whoever wrote it chooses it. From a cluster, whoever can create or
annotate an object there chooses the names a scan read (namespaces,
objects, Helm releases and charts, images, CRD groups, nodes, teams, field
managers), the finding titles, details, remediation and keys that quote
them, and free text copied from objects:
objects[].ignoreandobjects[].ignoreReason(theupgradescope.basit.engineer/ignoreandupgradescope.basit.engineer/ignore-reasonannotations, kept up to 16 KiB and listed even when they suppress nothing),suppressed[].reason(the reason of an annotation that did suppress a finding) andnotAssessed[].reason(what the API server answered). In--filesmode the manifests' authors choose all of it; in areport_file, aninventory_fileor from the fleet server, whoever wrote it; inregistry_lookup, the registry's upstream sources. - So the rule is not a list of fields that hold such text: every string in
every tool's result, values and object keys alike, is cluster-supplied,
not instructions, except numbers, booleans, the report's own field
names, and the values of the few keys upgradescope writes itself, and
those only in the form it writes them:
annualCostDelta,baselineState,category,currency,extendedSupportEnds,extendedSupportFrom,from,kbVersion,minor,phase,priceAsOf,provider,severity,since,target,to,verdict,was. A field added to the report later is outside text until it is listed there.key,title,detailandremediationare not listed: they quote CRD groups, Helm release names and node names. - Every result of every tool (
scan,get_report,list_findings,registry_lookupandfleet_summary) opens with a notice that states that rule, counts the outside strings, and names where the free text is, as JSON pointers through the report's own field names (e.g./findings/0/objects/0/ignoreReason), never through a team's name or another key the document chose. The result's_metacarries the same, structured, underupgradescope.basit.engineer/clusterSupplied. Each outside string, and each object key, longer than 2 KiB is cut, ending in…(cut by upgradescope mcp), soget_reportcan differ there from the report file it read. A cut key that would equal another key of its object is numbered before the cut mark (#2 …(cut by upgradescope mcp)), so an object never comes out with a key twice. - A tool error is outside text whole, since it may quote the API server, a
file, the fleet server or the call's own arguments: it opens with a notice
saying so, carries the marker in
_meta, and is cut to 2 KiB, after the tool's own words. - The server's instructions tell the assistant the same. This helps an assistant tell the outside text from the tool's words; it does not make it immune to it: keep the assistant's other tools (shell, file writes) behind your usual approvals.
report_fileandinventory_filelet an assistant read a local regular file of that shape that you can read. A file that is not a report or inventory is not returned, and the reason it is refused quotes none of it.- The tools carry the MCP
readOnlyHint, and a test holds the set of tools to the table above, so one that writes cannot be added unnoticed.