charly-mcp-cmd
Recipe card from the charly-build plugin (Commands — runtime CLI verbs).
MCP - Model Context Protocol (client + server)
Section titled “MCP - Model Context Protocol (client + server)”charly speaks MCP in both directions. This skill covers both:
- Client — the declarative
mcp:check verb: connect to any MCP server declared viamcp_provideand probe/call/read. Authored as anmcp:step in a candy/box plan and run viacharly check live <image> --filter mcp; it is served OUT-OF-PROCESS bycandy/plugin-mcpand has no hostcharly checksubcommand (parallel to thekube:/spice:/adb:/appium:plugin verbs). Used to test MCP endpoints shipped byjupyter-mcp,chrome-devtools-mcp, or the charly server itself. - Server —
charly mcp serve: expose the entire charly CLI surface (build + test + deploy modes; one tool per leaf command) as MCP over Streamable HTTP or stdio. Used by LLM agents (Claude Code, Open WebUI, OpenClaw) to drive charly remotely. Deployed in-container via thecharly-mcplayer. The tool catalog includes a project-scaffolding + YAML-editing + file-write authoring surface, so an agent can build ancharlyproject from scratch over RPC — see “Authoring tools” below.
Both surfaces are legs of the SAME plugin candy: candy/plugin-mcp serves verb:mcp (the client) AND command:mcp (the server, serve.go), sharing one github.com/modelcontextprotocol/go-sdk; charly’s core links NO MCP SDK — its host half is the SDK-free charly __cli-model seam (charly/cli_model_cmd.go).
Part 1 — Client (the declarative mcp: check verb)
Section titled “Part 1 — Client (the declarative mcp: check verb)”Overview
Section titled “Overview”The mcp: check verb connects to Model Context Protocol servers declared by running containers via mcp_provide, using github.com/modelcontextprotocol/go-sdk (v1.5.0). Seven methods cover the full MCP client surface: ping, servers, list-tools, list-resources, list-prompts, call, read. No MCP URL argument is ever typed by the user — the out-of-process plugin reads the target image’s ai.opencharly.mcp_provide OCI label via the generic cc.ResolveImageLabel reverse-leg, resolves {{.ContainerName}} templates, applies pod-aware localhost rewriting, and maps the container-network URL to the published host port via cc.ResolveEndpoint (the host owns that machinery behind the class-generic reverse-legs).
Served out-of-process — no host CLI subcommand. The verb is a DECLARATIVE check verb only; there is no charly check subcommand for it (just like kube:/spice:/adb:/appium:). The MCP-client implementation (the go-sdk dial + the 7 methods) lives in candy/plugin-mcp, an out-of-tree charly plugin that charly’s loader go-builds on the host and serves out-of-process over go-plugin gRPC (LocalTransport). A check: step carrying mcp: dispatches through the provider registry exactly like a built-in verb (ResolveVerb("mcp") → grpcProvider → Provider.Invoke with the full Op marshaled + a CheckEnv snapshot). Exercise it with charly check live <image> --filter mcp.
Authoring shape
Section titled “Authoring shape”Each mcp: step is a check: step — an ordered list item under a candy/box plan:. The method name is the scalar value for a bare-method step (mcp: ping), or the method: key of the mcp: map when the step carries mcp-exclusive fields (tool:, uri:, input:, mcp_name:) — those live INSIDE the mcp: map. Only the shared matchers (stdout:, stderr:, exit_status:) and context:/timeout:/id: stay siblings. See /charly-check:check for the parent router and the complete method allowlist. Example:
- check: jupyter exposes its core notebook mcp tools context: [deploy] mcp: list-tools stdout: - contains: insert_cell - contains: execute_cellQuick Reference
Section titled “Quick Reference”| Action | Declarative step | Description |
|---|---|---|
| Ping | mcp: ping |
Liveness check — emits ok on a successful Ping RPC |
| Enumerate | mcp: servers |
List MCP servers declared by the image (no dial) |
| List tools | mcp: list-tools |
Tool name + first-line description per line |
| List resources | mcp: list-resources |
URI + name + MIME type per line |
| List prompts | mcp: list-prompts |
Prompt name + description per line |
| Call | mcp: {method: call, tool: …} (+ optional input:) |
Invoke a tool; emits TextContent payload |
| Read | mcp: {method: read, uri: …} |
Read a resource; emits Text content |
The tool:/uri:/input:/mcp_name: fields live INSIDE the mcp: map; a
bare-method step (mcp: ping) uses the scalar form. Every method accepts:
mcp_name: <server>(in the map) — disambiguate when the image declares multiplemcp_provideentriestimeout: <duration>(a shared#Opsibling) — per-operation timeout (default 30s)- the shared matchers (
stdout:,stderr:,exit_status:) as siblings
Output is always tab-separated plaintext fed to the matcher pipeline (no --json form — that was a property of the retired host CLI). Run a candy’s baked mcp: steps against a live deployment with charly check live <image> --filter mcp.
Architecture
Section titled “Architecture”The host owns all podman / OCI-label / port-mapping machinery behind the class-generic reverse-legs (cc.ResolveImageLabel for the declared servers + cc.ResolveEndpoint for the host-routable dial address); the out-of-process candy/plugin-mcp provider resolves its endpoint through them and builds a single *mcp.ClientSession per invocation. The plugin needs no container inspection at all.
- Container resolution (host):
resolveContainer(image, instance)→charly-<image>[-<instance>]. - Image ref + metadata (host):
containerImageRef→ExtractMetadata→meta.MCPProvide(read from OCI labelai.opencharly.mcp_provide). - Template substitution (host): any
{{.ContainerName}}in the URL is replaced with the resolved container name. - Pod-aware rewrite (host):
podAwareMCPProvidesfolds same-image entries so the URL host becomeslocalhost(identical to the CHARLY_MCP_SERVERS path used bycharly config). - Host-side port rewrite (host): the load-bearing piece. The host parses the URL; if the host is the container name or
localhost, it looks up the published host port for the URL’s port viapodman inspect(sameNetworkSettings.Portsdata that powers${HOST_PORT:N}in declarative tests) and rewrites to127.0.0.1:<host-port>. Non-matching hosts (external URLs) pass through unchanged. The pre-resolvedmcp_provides+ the single picked endpoint travel to the plugin in theCheckEnvsnapshot. - Transport pick (plugin):
transport: http(or empty) →StreamableClientTransport{Endpoint};transport: sse→SSEClientTransport{Endpoint}; any other string errors at dial time with the declared transport echoed. - Session (plugin):
mcp.NewClient(…).Connect(ctx, transport, nil)runs the MCPinitializehandshake and returns an openedClientSession; the provider evaluates the step’s matchers itself and returns a{status,message}verdict to the host. Adefer session.Close()always fires.
Source: the endpoint resolution lives in candy/plugin-mcp (resolve.go — the cc.ResolveImageLabel + cc.ResolveEndpoint reverse-legs), alongside the MCP client (the 7 methods + the dial: provider.go dispatch + methods.go client layer). See /charly-internals:plugin for the out-of-process provider model and /charly-internals:go for the host-side map.
Methods
Section titled “Methods”Each method is shown as the declarative mcp: step you author. Run them against a live deployment with charly check live <image> --filter mcp.
- check: the jupyter mcp server responds to ping context: [deploy] mcp: ping stdout: equals: okCalls ClientSession.Ping(ctx, nil). Passes iff the server responds. Useful as a liveness check, often paired with a short timeout:.
Servers (discovery only, no dial)
Section titled “Servers (discovery only, no dial)”- check: the image advertises the jupyter mcp server context: [deploy] mcp: servers stdout: contains: jupyter # emits "<name>\t<url>\t<transport>" per server, e.g. jupyter http://localhost:8888/mcp httpReads mcp_provide from the OCI label, applies template substitution + pod-aware rewrite, emits the result. No MCP handshake — metadata only. Useful for confirming which server names the image advertises before dialing.
List tools / resources / prompts
Section titled “List tools / resources / prompts”- check: the jupyter mcp server exposes its notebook tools context: [deploy] mcp: list-tools stdout: - contains: list_notebooks # "list_notebooks\tList all notebooks accessible in the workspace." - contains: execute_cellPlaintext output is tab-separated (name\tfirst-line-of-description) for easy contains: matching without JSON parsing. Multi-line descriptions collapse to the first non-empty line. list-resources emits uri\tname\tmime; list-prompts emits name\tdescription. The provider automatically pages through NextCursor so all results return in a single invocation.
Call a tool
Section titled “Call a tool”- check: list_notebooks returns successfully context: [deploy] mcp: method: call tool: list_notebooks # required input: "{}" # optional JSON arg blob; omit for zero-arg tools exit_status: 0 # assert no IsError (shared #Op sibling)The input: field is the tool’s arguments as a JSON object (optional — omit for zero-arg tools), parsed with encoding/json. Returned TextContent blocks emit one per line; ImageContent / AudioContent emit a [image content: <mime>, <N> bytes] placeholder. If the server sets IsError: true, the step FAILS with the error text.
Read a resource
Section titled “Read a resource”- check: reading a resource returns a non-empty body context: [deploy] mcp: method: read uri: file:///workspace/data.txt # required stdout: - matches: "." # non-empty bodyCalls ReadResource(URI: …). Text content is emitted to stdout; binary blobs emit a [binary resource …] placeholder.
Disambiguating multiple servers
Section titled “Disambiguating multiple servers”When an image declares more than one mcp_provide entry, every mcp: step requires the mcp_name: modifier; without it the step FAILS with image provides multiple mcp servers; use mcp_name (available: jupyter, chrome-devtools):
- check: the chrome-devtools mcp server responds to ping context: [deploy] mcp: method: ping mcp_name: chrome-devtoolsNo existing image ships multiple providers today — jupyter layers expose one server (jupyter), chrome-devtools-mcp exposes one (chrome-devtools). The disambiguation exists for future compositions.
Port-publishing gotcha (encountered during live testing)
Section titled “Port-publishing gotcha (encountered during live testing)”Symptom: a mcp: ping step (e.g. via charly check live <image> --filter mcp) fails with:
mcp chrome-devtools: container port 9224/tcp is not published to a host port;declare `ports: [9224:9224]` in the image or run the test from inside the podCause: the image’s OCI label declares the port (e.g., chrome-devtools-mcp adds 9224 to sway-browser-vnc’s port list), but the running container’s quadlet doesn’t publish it because charly.yml’s per-image port: entry replaces (not appends to) the image-declared list. When a new mcp-providing layer is added to an image that already has a charly.yml entry with an explicit port: list, the new port silently drops out.
Fix: either add the mcp port to the instance’s charly.yml entry:
sway-browser-vnc: pod: image: sway-browser-vnc port: - "5900:5900" - "9250:9222" - "9224:9224" # ← add this… or remove the port: override entirely so the image default (which already includes 9224) applies. Re-run charly config <image> and restart the service (charly stop && charly start — charly update may no-op if the image tag hasn’t changed).
Verification that the fix worked:
charly status <image> --json# Expect: the mcp port listed in the port mappings with a non-null host portcharly check live <image> --filter mcp# the mcp: ping step passesInstance-specific deployments (the -i <instance> form) typically have their ports declared at create-time and are less prone to this drift.
Transport dispatch
Section titled “Transport dispatch”Declared transport: |
SDK transport | Notes |
|---|---|---|
http or empty |
StreamableClientTransport{Endpoint} |
Project default; jupyter-mcp and chrome-devtools-mcp both speak this |
streamable, streamable-http |
StreamableClientTransport{Endpoint} |
Aliases |
sse |
SSEClientTransport{Endpoint} |
Legacy SSE servers |
| anything else | error | "unsupported mcp transport %q (expected http or sse)" |
The SDK’s transports are struct-literal constructors — there is no NewStreamableClientTransport(...) helper. Setting Endpoint is the only required field.
Output format: plaintext
Section titled “Output format: plaintext”Every method emits author-friendly plaintext with one record per line:
ping→oklist-tools→<name>\t<first-line-of-description>list-resources→<uri>\t<name>\t<mime-type>list-prompts→<name>\t<description>call→ concatenatedTextContent.Textpayloads, one per lineread→ concatenatedResourceContents.Text, one per lineservers→<name>\t<url>\t<transport>
The plaintext is fed straight to the matcher pipeline. There is no JSON output form — the retired host CLI’s --json flag went away with the subcommand; the out-of-process candy/plugin-mcp provider returns the plaintext to the host, which evaluates the step’s matchers.
Declarative authoring examples
Section titled “Declarative authoring examples”Checks currently shipping in the three provider candies (candy/jupyter/charly.yml, candy/jupyter-ml/charly.yml, candy/chrome-devtools-mcp/charly.yml), as list items under each candy’s plan::
# Liveness check — fastest sanity verification- check: the jupyter mcp server responds to ping context: [deploy] mcp: ping timeout: 10s
# Catalog assertion — ensure the server exposes the tools we expect- check: the jupyter mcp server exposes the expected tools context: [deploy] mcp: list-tools stdout: - contains: insert_cell - contains: execute_cell
# Real tool invocation — exercises the full request/response path- check: list_notebooks returns successfully context: [deploy] mcp: method: call tool: list_notebooks input: "{}" exit_status: 0
# Tool with arguments- check: get_notebook returns a notebook with cells context: [deploy] mcp: method: call tool: get_notebook input: '{"path":"getting-started.ipynb"}' stdout: - contains: cells
# Resource read- check: a prompt resource reads back non-empty context: [deploy] mcp: method: read uri: file:///workspace/prompt.txt stdout: - matches: "."Each mcp: step is a check: step — a deterministic probe that satisfies the mandatory-ADE gate. Deploy-context only. mcp: steps require a running container with the mcp port published; charly box validate rejects build-context mcp steps at authoring time, and charly check box skips them at runtime with the message "mcp: <method> requires a running container (skip under charly check box)". Follow the same rule as the other four live-container verbs — cdp, wl, dbus, vnc.
Validator coverage
Section titled “Validator coverage”charly box validate + the CUE schema enforce (the mcp verb is an out-of-process plugin — its method-name enum is validated by CUE on core #Op, its required-modifier checks run in candy/plugin-mcp at dispatch):
- Method name must be in
mcpMethods(7 entries); unknown methods list the allowed set in the error. contextmust includedeploy; a build-context mcp step raises"mcp: verb requires context:\"deploy\"".callrequirestool:; missing field raises"mcp: call requires modifier \"tool\"".readrequiresuri:.
No other required modifiers — ping, servers, list-* take only the optional mcp_name:.
Part 2 — Server (charly mcp serve)
Section titled “Part 2 — Server (charly mcp serve)”Overview
Section titled “Overview”charly mcp serve runs the charly CLI as an MCP server. Every leaf command in the Kong CLI tree — box.build, status, test.mcp.ping, config.setup, box.new.project, candy.add-rpm, etc. — becomes a callable MCP tool. Tool catalogs are auto-generated from Kong struct tags by reflection — host-side, over the hidden charly __cli-model seam — with no hand-written schema per command. Result: one tool per leaf command covering the entire build + test + deploy surface, including the MCP-first authoring verbs (image.{new.project, new.image, set, add-layer, rm-layer, fetch, refresh, write, cat} + layer.{set, add-rpm, add-deb, add-pac, add-aur}).
charly mcp serve # Streamable HTTP on :18765/mcpcharly mcp serve --listen 127.0.0.1:9999 # Custom portcharly mcp serve --path /api/mcp # Custom HTTP path prefix (default /mcp)charly mcp serve --stdio # Stdio transport for editor/LLM integrationcharly mcp serve --read-only # Skip registering the destructive toolsServer and client share one github.com/modelcontextprotocol/go-sdk inside the same plugin candy, so the wire format is identical and the mcp: ping check verb works against it unchanged.
Architecture
Section titled “Architecture”The server is the command:mcp leg of candy/plugin-mcp (serve.go), fed by ONE hidden host seam: charly __cli-model (charly/cli_model_cmd.go), which emits charly’s ASSEMBLED Kong command tree — the core CLI struct plus the builtin command-provider grammar — as an sdk.CLIModel JSON document on stdout. Core links no MCP SDK.
-
Reflection (host-side) — at startup
buildMcpServer(bin, readOnly, noDefaultRepo)fork/execscharly __cli-model(fetchCLIModel— deliberately with NO project prefix: the model needs no project, so a cold-start network blip can never strip the project prefix) and registers one tool per model leaf viacliLeafToTool(leaf, destructive):- Name: dot-joined command path (e.g.
box.build,test.mcp.ping,config.setup). - Description: the leaf’s help, with a
[destructive: …]annotation appended for mutating tools. - InputSchema: JSON schema built from the model’s args (
argToSchema) — positionals become required properties, flags optional ones,enum:/default:surfaced. Every schema hasadditionalProperties: false— unknown keys are rejected by the SDK’s input validation before the handler runs. The schema validator is LLM-honest about the allowed surface. - Annotations: destructive tools get
DestructiveHint: &true; everything else getsReadOnlyHint: true.
- Name: dot-joined command path (e.g.
-
Destructive gating —
mcpDestructivePaths(serve.go) is an explicit allowlist of mutating tool paths (the lifecycle, config, secrets, deploy, image build/scaffold/edit, layer edit, VM, udev, alias, record, tmux, and settings families). When--read-onlyis set,buildMcpServerskips registration entirely rather than gating at runtime.box.fetch(idempotent, additive cache prime) andbox.cat(read-only file read) are not in the destructive set despite living in the authoring family — they’re safe under--read-only. -
Tool invocation — fork/exec, no in-process capture —
makeToolHandler(bin, prefix, leaf)returns a closure. On call: decode the MCP JSON arguments, reconstruct a[]stringargv viaargvFromJSON(…)(booleans → bare flag, slices → repeated--flag=value, positionals in model order), thenforkCharlyrunscharly <prefix> <path> <args…>as a SUBPROCESS andassembleToolTextreturns the captured stdout/stderr as a singleTextContent. Errors becomeIsError: truetool results, not MCP-protocol errors — the LLM sees the failure text. The fork/exec design REPLACED the former in-process capture model wholesale: there is noos.Stdoutredirect, no capture mutex, and subprocess output cannot leak past its own tool result. -
Project prefix — every tool call carries the managed prefix from
computeProjectPrefix(see “Project-dir wiring” below);childCharlyEnvstripsCHARLY_PROJECT_DIR/CHARLY_PROJECT_REPOfrom the child environment so the prefix stays authoritative. -
Transport —
mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil)wraps the server for HTTP mode;--stdioruns the same server over the stdio transport.
Deployment: the charly-mcp layer
Section titled “Deployment: the charly-mcp layer”Composing charly-mcp into an image deploys the server via supervisord:
# candy/charly-mcp/charly.yml (summary; the candy's required version: is elided)charly-mcp: candy: description: Runs the charly CLI itself as an MCP server (charly mcp serve) over Streamable HTTP on :18765charly-mcp-candy: candy: - charly - supervisordcharly-mcp-port: port: - 18765charly-mcp-mcp_provide: mcp_provide: - name: charly url: "http://{{.ContainerName}}:18765/mcp" transport: httpcharly-mcp-volumes: volumes: - name: project # Bind-mount the project root; see below path: /workspacecharly-mcp-env: env: CHARLY_PROJECT_DIR: "/workspace"charly-mcp-service: service: - name: charly-mcp exec: /usr/local/bin/charly mcp serve --listen :18765 restart: always enable: true scope: systemProject-dir wiring — build-mode tools (box.build, box.inspect, box.list.*) resolve charly.yml via os.Getwd(). Inside the container, cwd is /workspace (set by the charly-mcp layer’s CHARLY_PROJECT_DIR env + volume: declaration). Three deployment patterns, in order of progressively less local setup:
-
Bind-mount — the canonical
charly-mcppattern. The candy shipsenv: CHARLY_PROJECT_DIR: /workspace+volumes: project → /workspace; the deployer attaches a host checkout viacharly config <image> --bind project=/path/to/opencharly. The charly CLI’s global-C/--dir/CHARLY_PROJECT_DIRflag honours the env var before Kong dispatch, callingos.Chdir(CHARLY_PROJECT_DIR)once. Use this when the agent should see your in-flight local edits. The volume NAME isproject(stable bind-mount API); the in-container PATH is/workspace(the generic name works whether the contents are an opencharly checkout or any other workspace). -
Remote pin — set
CHARLY_PROJECT_REPO=opencharly/charly@<sha-or-ref>in the container env (e.g. viacharly config <image> -e CHARLY_PROJECT_REPO=...). The charly CLI clones (or hits its~/.cache/charly/repos/cache) and chdirs into the cache path before Kong dispatch. No bind mount required. Use this for reproducible agent runs against a pinned upstream. -
Auto-default —
charly mcp servewith no charly.yml reachable at cwd falls back togithub.com/opencharly/charlyby prepending a managed--repo defaultprefix to every project-dependent tool call (computeProjectPrefixincandy/plugin-mcp/serve.go); the childcharlyresolves + fetches the default-repo cache. The fallback fires regardless ofCHARLY_PROJECT_DIRbeing set — the check is whether the cwd actually containscharly.yml, not whether the env var is populated (andchildCharlyEnvstrips the env from children so the prefix stays authoritative). This matters because thecharly-mcplayer permanently setsCHARLY_PROJECT_DIR=/workspace: a deployer who forgets the--bindstill gets a working MCP server backed by the upstream repo. Pass--no-default-repoto opt out — the server still runs, and project-dependent tools error at call time (the child reports “no project”). This is the only command surface that auto-fetches; the top-level CLI stays opt-in.
See /charly-image:image “Project directory resolution” for the flag/env semantics; the implementation is computeProjectPrefix + childCharlyEnv in candy/plugin-mcp/serve.go.
Composition style — charly-mcp uses candy: [charly, supervisord] (meta-layer composition) rather than require: (hard prerequisite) because it adds no install of its own — it’s pure wiring. Boxes that want the MCP server add charly-mcp to their candy list; boxes that just want the charly binary continue to use the charly candy alone. Both candy: and require: reference other candies, but only candy: lets the using candy ship no install files.
Verifying end to end
Section titled “Verifying end to end”# Build an charly-mcp-bearing image (e.g. charly-arch) and start it:charly box build charly-archcharly config charly-arch --bind project=/home/you/opencharlycharly start charly-arch
# From the host — the container's mcp_provide URL is auto-rewritten to the published host# port, then the candy's baked declarative mcp: steps (each carrying mcp_name: charly)# run against the live deployment:charly check live charly-arch --filter mcp# the mcp: ping / list-tools / call (version, box.list.boxes) steps all pass# list-tools → the full tool catalog; call version → 2026.nnn.nnnn; call box.list.boxes →# charly-arch [testing], arch [testing], … (reads charly.yml from the bind-mounted /workspace)The deploy-context check: step nodes in candy/charly-mcp/charly.yml cover this exact sequence: service-running, port-reachable, mcp: ping, mcp: list-tools, mcp: call tool=version, mcp: call tool=box.list.boxes (the last proves the bind-mount + CHARLY_PROJECT_DIR wiring). For an ad-hoc MCP client outside charly, point any MCP-speaking tool (e.g. /charly-tools:mcporter) at the published host port.
Authoring tools (build-from-scratch over MCP)
Section titled “Authoring tools (build-from-scratch over MCP)”Every CLI verb under charly box … and charly candy … auto-becomes an MCP tool via Kong reflection. The authoring surface added for “build a project from scratch using only charly mcp” exposes these tools:
| MCP tool | What it does |
|---|---|
box.new.project |
Scaffold charly.yml (the build vocabulary is embedded in the charly binary), candy/, .gitignore. |
box.new.box |
Append a new image entry to charly.yml. |
box.new.candy |
Scaffold candy/<name>/charly.yml with a stub. |
box.set |
Set any value in charly.yml by dot-path (defaults.tag, images.foo.layers, …). Value is parsed as YAML. |
box.add-candy / box.rm-candy |
Append / remove a layer from an image’s candy: list (idempotent). |
candy.set |
Set any value in candy/<name>/charly.yml by dot-path. |
candy.add-rpm / candy.add-deb / candy.add-pac / candy.add-aur |
Append packages to a layer’s <format>.packages list. Idempotent. Upgrades scaffold’s null package: value to a real sequence. |
box.fetch / box.refresh |
Pre-prime / re-clone the remote-repo cache. Spec defaults to default (opencharly/charly). |
box.write / box.cat |
Write / read any file under the project root — escape hatch for free-form auxiliary files (pixi.toml, package.json, root.yml, scripts, *.service). Path is resolved against os.Getwd() and rejected if it escapes the project root. |
All YAML edits go through the yaml.v3 node API (not value unmarshal) so comments and key order are preserved across edits. The generic comment-preserving setter/node helpers (kit.SetByDotPath / kit.MappingChild) live in sdk/kit/yaml.go (tested in sdk/kit/yaml_test.go); the box authoring editors live in candy/plugin-authoring/authoring_edit.go (P14b — moved from core charly/scaffold_cmds.go; tested in candy/plugin-authoring/authoring_edit_test.go); the candy authoring command is the compiled-in command:candy plugin (candy/plugin-candy/command.go).
End-to-end MCP-only worked example:
// All called as MCP tool calls (e.g. via an `mcp: call` check step, or any MCP client):box.new.project {"dir": "/tmp/hello"}box.new.box {"name": "hello", "base": "quay.io/fedora/fedora:43"}box.new.candy {"name": "hello-svc"}candy.add-rpm {"name": "hello-svc", "packages": ["openssh-server"]}box.add-candy {"image": "hello", "layer": "hello-svc"}image.validate {}box.build {"image": "hello"}box.inspect {"image": "hello"}Port choice
Section titled “Port choice”Default :18765 chosen for non-collision with other MCP layers:
8888— jupyter-mcp9224— chrome-devtools-mcp (via mcp-proxy)18789— openclaw gateway
Policy note
Section titled “Policy note”The server registers destructive tools with DestructiveHint: true rather than withholding them. The LLM runtime (Claude Code, Open WebUI) is responsible for acting on the hint — e.g. prompting the user before calling an annotated tool. For hostile-LLM scenarios or untrusted network deployments, run with --read-only (drops the 51 mutating tools at registration time) and/or restrict reach via the tunnel / Traefik candy.
Cross-References
Section titled “Cross-References”/charly-check:check— parent router for the declarativemcp:check verb. The fullmcpmethod allowlist (and how the verb relates to the other out-of-process live-container plugin verbs wl/cdp/vnc/dbus/record/kube/spice/adb/appium/libvirt) lives in its “Live-container verb catalog” + “Method allowlist — mcp” sections./charly-image:layer—mcp_provide/mcp_accept/mcp_requirefield reference for layer authoring./charly-core:charly-config— howmcp_providegets injected intocharly.ymlprovides.mcp:and synthesized intoCHARLY_MCP_SERVERSfor consumers atcharly configtime; pod-aware resolution tolocalhost; instance-aware MCP server naming with-<instance>suffix./charly-build:validate— authoring-time validation rules (method allowlist, required modifiers, scope enforcement)./charly-core:deploy—charly.ymlport:override semantics (the port-publishing gotcha lives here operationally)./charly-check:cdp— sibling live-container verb (Chrome DevTools Protocol)./charly-check:wl— sibling (Wayland desktop control)./charly-check:dbus— sibling (D-Bus calls/notifications)./charly-check:vnc— sibling (VNC framebuffer / input)./charly-jupyter:jupyter-mcp— the FastMCP server implementation layer (11 tools for notebook manipulation over CRDT: notebook_/cell_ + notebook_list_users + room_list; clients do not manage CRDT rooms — the server auto-attaches)./charly-selkies:chrome-devtools-mcp— the mcp-proxy wrapper around chrome-devtools-mcp (29 tools for browser automation)./charly-hermes:hermes— a consumer (mcp_accept: jupyter, chrome-devtools); use themcp:check verb (charly check live <consumer> --filter mcp) to verify the services hermes discovers are actually alive./charly-openwebui:openwebui— another consumer (mcp_accept: jupyter, chrome-devtools)./charly-jupyter:jupyter,/charly-jupyter:jupyter-ml,/charly-jupyter:jupyter-ml-notebook— images bundlingjupyter-mcp;charly check live <image> --filter mcpexercises the verb end-to-end./charly-selkies:sway-browser-vnc,/charly-selkies:selkies-labwc,/charly-selkies:selkies-labwc-nvidia— images bundlingchrome-devtools-mcp(transitively via the chrome metalayer)./charly-internals:go— host-side implementation map:check_endpoint_resolve.go(the host-endpoint reverse-legs —resolveImageLabel+resolveVerbEndpointthe mcp plugin pulls),cli_model_cmd.go(thecharly __cli-modelseam the server consumes; the server itself iscandy/plugin-mcp/serve.go),candy/plugin-box/validate_check.go(op-level deploy-scope enforcement; themcpmethod-name + required-modifier checks live incandy/plugin-mcp+ the CUE#Openum). The MCP CLIENT (the 7 methods + the go-sdk dial) lives out-of-process incandy/plugin-mcp— see/charly-internals:plugin./charly-coder:charly-mcp— the deployment layer that wirescharly mcp serveinto an image via supervisord. Includes the/workspacebind-mount (volume NAMEproject) +CHARLY_PROJECT_DIRenv var pattern for build-mode tools./charly-tools:charly— the underlying binary layer; required bycharly-mcp./charly-image:image— “Project directory resolution” subsection documents the-C/--dir/CHARLY_PROJECT_DIRglobal flag that makes the server’s project-dir bind-mount work./charly-coder:charly-arch,/charly-distros:charly-fedora— canonical images composingcharly-mcpfor remote charly-via-MCP deployments.
When to Use This Skill
Section titled “When to Use This Skill”MUST be invoked when the task involves Model Context Protocol on either side:
- Client — the declarative
mcp:check verb (served out-of-process bycandy/plugin-mcp; no hostcharly checksubcommand), probing/testing MCP servers declared viamcp_provide, examining MCP tool/resource/prompt catalogs, debugging the URL-rewriter or port-publishing behavior, or authoring deploy-contextmcp:step nodes in a candy/box plan. - Server —
charly mcp serveoperation, thecharly-mcplayer, destructive-hint policy, the--read-onlyfilter, Kong-reflection tool generation, the project-dir bind-mount pattern, or symptoms like “MCP tool returned empty output” (checkprintlnvsfmt.Printlnin the invoked command — the server capturesos.Stdout, not fd 1).
Invoke this skill BEFORE reading source code or launching Explore agents.
Workflow position: Test mode / live service operation. Client: deploy an image with mcp_provide, start it, then probe. Server: compose charly-mcp into an image, bind --bind project=/path/to/opencharly at config time, start the container, then consume from any MCP-speaking LLM runtime. Pairs with /charly-check:check (parent router + declarative-verb catalog), /charly-core:charly-config (consumer-side injection + --bind for the project dir), /charly-coder:charly-mcp (server deployment layer), and the consumer-layer skills (/charly-hermes:hermes, /charly-openwebui:openwebui).