MCP for AI agents
breeze-mcp exposes the Breeze toolchain โ 40 tools across generation,
introspection, verification, runtime debugging, Fleet tracing, and Docker
provisioning โ to an AI agent over the Model Context Protocol. It runs
standalone, or in-process inside a generated application, serving its
own control plane beside its own traffic.
app := breeze.New(router, pool)
server, token, err := mcp.StartInProcess(app, mcp.InProcessConfig{
Mode: mcp.ModeAppRuntime, // required โ see "Two kinds of server"
Port: 2000, // control address
Token: os.Getenv("BREEZE_MCP_TOKEN"),
})
if err != nil {
log.Fatal(err) // a port conflict is fatal at startup
}
go server.Serve()
app.Run(3000, true) // app address Four addresses, never conflated
| Kind | What listens there | Who dials it | Named by |
|---|---|---|---|
| Control address | a breeze-mcp process in --mode generator | the MCP client, as a configured server | control_port / control_token |
| App address | the generated application itself | runtime/fleet tools, as an argument | app_port |
| App-MCP address | the app's own embedded app-runtime endpoint | the MCP client, as a second server | app_mcp_port |
| Aggregator address | the Fleet Aggregator's API/WS endpoint | fleet tools, as an argument | a fleet registry entry |
The first and third are both MCP, which is exactly why they must not be conflated: the control address serves the generator-level toolchain over that container's source tree, so whoever holds its token can rewrite the project. The app-MCP address serves read-only introspection of the running process โ it has no mutating tool registered at all.
A tool call travels through a control-plane connection (configured
once, as a server URL). A tool argument such as service_url points
at something else entirely โ the application's own address. So one
question โ "what routes is the users service serving?" โ involves two
addresses at once: the control connection carries the call, and the app
address is what the call is about. Conflating them produces the most
misleading failure available: a control port answers an app-port request
with a well-formed MCP rejection rather than a connection error.
Two kinds of server: --mode
There is no default, and construction fails without one:
--mode generator | --mode app-runtime | |
|---|---|---|
| Answers | "help me build and change this project" | "help me understand what this instance is doing right now" |
| Tools | the full toolchain โ generate, plan, verify, provision, plus every read-only tool | live introspection only |
| Mutating tools | registered | not registered at all |
| Belongs | a developer's machine, a build agent | inside a deployed process |
The distinction is structural, not configuration: an app-runtime server
doesn't check a permission before running breeze_generate โ it has no breeze_generate to run. No token scope and no argument trick reaches one,
because there is nothing to reach. Choosing generator as a default would
mean a deployed app that forgot the flag silently exposes project
generation and Docker provisioning to whoever holds its token โ so neither
mode is assumed.
The handshake reports which one you reached:
{
"protocolVersion": "2024-11-05",
"serverInfo": {"name": "breeze", "version": "v0.1.0"},
"breezeServerKind": "generator"
} What a token may reach: --scope
--mode decides what a server has. --scope decides what a credential reaches โ independent layers, both worth using. Every tool belongs to
exactly one of eight categories:
| Category | What it does |
|---|---|
generation | writes project files โ breeze_new, breeze_generate, breeze_add |
introspection | reads what exists in a project |
planning | previews changes, holds change sets open |
knowledge | maintains and searches llms.txt, suggests next steps |
verification | runs the Go toolchain โ and therefore project code |
runtime | reads live state from a running service โ routes, errors, logs, performance, breeze_diagnose_service |
fleet | reads a Fleet Aggregator: traces, topology, contract violations |
provisioning | drives Docker: builds images, starts and removes containers |
breeze-mcp --mode generator --port 2000 --scope fleet,runtime Omitting --scope grants every category โ every existing deployment's
current behaviour. What is not silent is the risky combination: a
generator-mode server on a non-loopback bind with an unscoped token says so
at startup.
Granting is by category, never by tool name โ a token minted with 40 tool names would be stale the moment a tool was added. The handshake reports both the granted and the full known set, so an agent can tell "never built" from "withheld from my token" โ a difference that decides whether to give up or ask for a wider credential:
"breezeCapabilities": {
"granted": ["fleet", "runtime"],
"known": ["fleet", "generation", "introspection", "knowledge",
"planning", "provisioning", "runtime", "verification"],
"scoped": true
} A human, or tooling that would rather not implement a handshake, can check a token directly:
curl -H "Authorization: Bearer $BREEZE_MCP_TOKEN" http://127.0.0.1:2000/mcp/features What a refused call looks like
An out-of-scope tool is absent from tools/list, but a client working
from a stale cache may call one anyway. The refusal is a tool result,
not a JSON-RPC error โ a -32602 would tell a model its request was
malformed and invite it to reformat and retry forever:
{
"isError": true,
"structuredContent": {
"tool": "provision_service",
"refused": true,
"reason": "outside this token's granted capabilities",
"requires": "provisioning",
"granted": ["fleet", "runtime"],
"retry_will_succeed": false
}
} Where a tool may reach on disk: --workspace
--mode decides what tools exist; --scope decides which of them a
credential reaches; neither says where they may operate โ and most of
these tools take a path.
breeze-mcp --mode generator # confined to the CWD (default)
breeze-mcp --mode generator --workspace /srv/projects # one tree
breeze-mcp --mode generator --allow-any-path # no confinement This is not tidiness. breeze_verify_project runs go test in the
directory it's given, and go test compiles and executes whatever it
finds there โ an unconfined server handed {"path": "/etc"} runs that
directory's code under the server's own identity. resolvePath is the only way any tool turns a caller's path into one it will touch, so a
new tool cannot forget the check.
Refused: a path outside every root (absolute or by ../ traversal); a path
that resolves outside through a symlink (both the roots and the
candidate are symlink-resolved before comparison โ a prefix test alone is
not a containment test); a path through a Windows junction (its target
can't be established, so containment can't be, so it's refused rather than
assumed). Allowed: a path that doesn't exist yet โ breeze_new's target โ
checked against its nearest existing ancestor instead.
--allow-any-path exists for the deployment where the process boundary is
already the security boundary โ a disposable container. It's mutually
exclusive with --workspace.
Transports
stdio โ with --mode and no --port, breeze-mcp speaks stdio and the
client launches it as a subprocess. No port, no token: the process boundary
is the trust boundary.
network โ --port starts an HTTP+SSE server: bearer token required on
every request including the handshake, Origin validated, loopback unless Host says otherwise.
in-process โ mcp.StartInProcess (shown above) serves the same
security posture from inside a running application, with no separate
binary.
The in-process endpoint serves a deliberate subset
16 of the 40 tools โ the ones that generate, plan, verify, or provision are
excluded, for two independent reasons: concurrency (they chdir and
replace os.Stdout under a process-wide lock, which inside a live app
resolving relative paths for real traffic would be actively dangerous), and no source tree (a deployed binary was built from a module cache, not a
clone โ there's nothing on disk for those tools to operate on). InProcessConfig.AllowWorkspaceTools restores the excluded set, for a
development container where the app runs from its own clone and serves no
real traffic โ a deployed app should not set it. mcp.Tools() and mcp.ExcludedTools() report both lists at runtime.
scope, _ := mcp.NewScope(mcp.CapFleet) // traces and topology, nothing else
server, token, _ := mcp.StartInProcess(app, mcp.InProcessConfig{
Mode: mcp.ModeAppRuntime,
Port: 2000,
Token: os.Getenv("BREEZE_MCP_TOKEN"),
Scope: scope,
}) Provisioning containers is boxed in too
provision_service starts a second breeze-mcp inside each container
it creates, passed --workspace /workspace and nothing wider. Nothing in a
provisioning request can escape that: the docker object decodes
strictly, so privileged, volumes, network_mode, cap_add, devices and the rest are refused rather than ignored โ a silently dropped
option would look to the caller like an honoured one. Every docker invocation passes through one function that checks the complete argv for
mount, privilege, namespace and entrypoint flags. There is no opt-in for a
host mount: an operator who genuinely needs one has docker run, which is
a deliberate act by someone who already holds Docker access, rather than a
JSON field an agent can populate.
No tool anywhere in internal/mcp or internal/generator builds a shell
command โ every exec.Command passes an argument array, so ; rm -rf / as a service name arrives at Docker as one literal argument,
which Docker rejects.
The report the framework gives about itself
GET /dashboard/api/diagnostics?subsystem=mcp is the diagnostic an agent
needs most โ because breeze_diagnose_service reads this same registry, so
the endpoint serving the call was previously the one subsystem missing from
its own report. It surfaces three states no error anywhere else reveals: a
scope withholding a tool, generator mode running with no source tree, and AllowWorkspaceTools left on in production. See Diagnostics.
The five layers, together
--mode (what exists) โ --scope (what a token reaches) โ --workspace (where it may act on disk) โ transport (stdio / network /
in-process) โ provisioning boundaries (what a spawned container itself can
reach). Each layer is independent and each is reported back to the client
at handshake time, so a missing capability can always be attributed to the
right one rather than debugged by elimination.