Skip to content

MCP exposure

An MCP exposure makes selected parts of a running Peppy stack available to Model Context Protocol clients. A client can read camera frames, look through a camera, call services, and run long-running actions through one HTTP endpoint. The client does not need a Peppy session, access to the messaging layer, or knowledge that the system uses Peppy.

An exposure is an allowlist. In one document, you choose the contract members to publish and give each one a stable public name, a description, and operational policies. A launcher names the exposures to serve, and the daemon runs the MCP server that is built into the peppy binary for them: one process, one loopback port, one endpoint per exposure. Nothing is generated or built. Anything you do not name is not reachable through the endpoint. This remains true even if a client guesses a name or knows the internal one. A client sees only the selected members, even when the underlying stack contains more.

You do not write the public request and response schemas by hand. Peppy derives them from the referenced contracts and daemon interfaces, at check time and again when the server starts, so the published schemas cannot drift from the definitions behind them.

An exposure draws from contracts, which node instances implement, and from the daemon interfaces that peppy serves. Each Peppy communication type has an MCP equivalent:

Contract memberPublished asWhat a client can do
TopicResourceRead the latest policy-approved snapshot and subscribe to updates
Topic with a picturePicture tool, beside the resourceCall it and get the latest frame as an image, with the rest of the message
ServiceToolCall it and get the answer in the same request; under a representation, the answer’s frame as an image
ActionAction-backed toolRun the goal inside the call, or as an MCP task: get a task handle, watch progress, confirm, cancel, and reconnect

An exposure that declares a call_record also publishes one tool of its own: the last calls of every tool that is not read-only, with the client that made each and how it ended.

The server supports MCP revision 2026-07-28 over Streamable HTTP. It includes the tasks extension (SEP-2663), which a client opts into per request; an action-backed tool runs as a task for a client that declares it and inside the call for one that does not.

An exposure is a standalone JSON5 document with peppy_schema: "mcp_exposure/v1". Its filename does not matter. The name:tag in its manifest identifies it, peppy repo index discovers it by its content, and a launcher lists it by that name:tag. The manifest may also carry labels, a list of free-form strings for whoever browses the repository.

Here is a complete example with two targets. The camera provides an image resource, the picture tool that shows its frame, and two tools. The recorder provides a record_episode tool that creates a task and requires confirmation.

exposures/camera_and_recording.json5
// The public MCP surface of one robot: a live camera to look through and an
// episode recorder to drive. Everything an MCP client can ever see is named
// in this file; anything the running stack also carries stays private.
//
// This document is the whole artifact. A launcher lists it under
// `source: { exposures: [...] }` and the daemon serves it from the server
// built into peppy; `peppy repo index --check --validate-mcp-exposures`
// checks it against the contracts it references, and `peppy mcp catalog`
// prints what an endpoint for it advertises.
{
peppy_schema: "mcp_exposure/v1",
manifest: { name: "camera_and_recording", tag: "v1" },
// Advertised to clients through `server/discover`. `instructions` is the
// prose a model reads before it decides what to call.
server: {
title: "OpenArm camera and recording",
instructions: "Observe the front camera and record teleoperation episodes.",
},
targets: {
// A LOGICAL target, not an instance. `front_camera` becomes the `link_id`
// of a `depends_on.contracts` slot of the server's node, so the launcher
// decides which running instance fills it. The same exposure serves a
// simulated camera and a real one.
front_camera: {
contract: {
name: "rgb_camera",
tag: "v1",
// An optional author pin: the sha256 of the exact contract document
// this file was written against (`sha256sum contracts/rgb_camera.json5`).
// With it, a launch that resolves other bytes for `rgb_camera:v1`
// refuses instead of publishing schemas this file never saw.
sha256: "44527ab0542ac56389806315f21c6977c535a1874527926fcc480dd57fb736ab",
},
// A topic becomes a resource: the latest policy-approved snapshot,
// which clients read and subscribe to. Under a representation a read
// serves the message without its frame, and the frame as a blob.
topics: [
{
member: "video_stream",
resource: "front_camera.latest_frame",
description: "Latest frame from the front-facing camera, JPEG encoded.",
// A snapshot older than this reads as stale rather than serving
// yesterday's frame as if it were current.
freshness: { max_age_ms: 2000 },
// Frames arriving faster than this are dropped before any decoding
// or transcoding runs. A 30 Hz camera does not become 30 Hz of
// JPEG encoding for a client that reads twice a minute.
update: { max_hz: 2 },
// Interpret the message as an image and publish it as JPEG. The
// roles name members of THIS topic's message format.
representation: {
image: "jpeg",
quality: 80,
fields: {
data: "frame",
encoding: "encoding",
width: "width",
height: "height",
},
},
// The frame is a variable-length byte array, so its serialized size
// has no static maximum and the runtime size policy is mandatory.
max_result_bytes: 524288,
on_oversize: "downscale",
// The same frame through a tool: a model that calls it gets the
// picture as an image, beside the rest of the message. It reads
// the snapshot the resource serves and takes no argument.
picture: {
tool: "front_camera.look",
description: "Look through the front-facing camera: the latest frame as a picture.",
},
},
],
// A service becomes a tool that answers within one request.
services: [
{
member: "video_stream_info",
tool: "front_camera.info",
description: "Report the camera's resolution, frame rate, and encoding.",
operation: "read_only",
deadline_ms: 2000,
},
{
member: "set_brightness",
tool: "front_camera.set_brightness",
description: "Set the camera brightness in device units.",
operation: "mutating",
deadline_ms: 2000,
// Narrows what the published input schema accepts. The contract's
// `i8` would allow -128..127; this exposure allows -64..64, and the
// refusal happens at the endpoint, before the Peppy graph is
// touched.
restrict: { value: { min: -64, max: 64 } },
},
],
},
recorder: {
// No pin: the exposure follows `episode_recording:v1` as its repository
// evolves, and each launch validates it against the bytes it resolves.
contract: { name: "episode_recording", tag: "v1" },
// An action becomes a tool. For a client that declares the MCP tasks
// extension the call returns a task handle, feedback drives the
// status message, and `tasks/cancel` forwards to the action's cancel
// path; for any other client the goal runs inside the call.
actions: [
{
member: "record_episode",
tool: "recorder.record_episode",
description: "Record one teleoperation episode to the local dataset.",
operation: "long_running",
// Advertised to the client: this one moves the robot.
safety_sensitive: true,
// The task parks in `input_required` until the client confirms
// through `tasks/update`. No goal is sent before that, and only
// a client with the tasks extension can call the tool.
confirmation_required: true,
deadline_ms: 900000,
},
],
// `finish_session` is live on the recorder and deliberately absent
// here. A member that is not selected is not reachable through the
// endpoint at all.
},
},
}

It selects from these two contracts:

contracts/rgb_camera.json5
{
peppy_schema: "contract/v1",
manifest: { name: "rgb_camera", tag: "v1" },
interfaces: {
topics: [
{
name: "video_stream",
qos_profile: "sensor_data",
message_format: {
frame: { $type: "array", $items: "u8" },
encoding: "string",
width: "u32",
height: "u32",
},
},
],
services: [
{
name: "video_stream_info",
response_message_format: {
width: "u32",
height: "u32",
frames_per_second: "u8",
encoding: "string",
},
},
{
name: "set_brightness",
request_message_format: { value: "i8" },
response_message_format: { applied: "i8" },
},
],
},
}
contracts/episode_recording.json5
{
peppy_schema: "contract/v1",
manifest: { name: "episode_recording", tag: "v1" },
interfaces: {
actions: [
{
name: "record_episode",
goal_service: { request_message_format: { episode_name: "string" } },
feedback_topic: { message_format: { frame: "u32" } },
result_service: { response_message_format: { frames: "u32" } },
},
],
services: [
{ name: "finish_session", response_message_format: { episodes_recorded: "u32" } },
],
},
}

finish_session is live on the running recorder and absent from the exposure. It stays private.

Each key under targets names a role, not a running node. A target names exactly one source: contract for the members of a contract, or daemon for the members of a daemon interface. The server’s manifest declares one depends_on.contracts slot per contract target, with the target name as its link_id, and the launcher connects that slot to a running instance at deploy time. The same exposure can therefore work with a simulated camera or a real one without being changed. A target name follows the rules of a node name, because it becomes a slot’s link_id.

A target must select at least one member, and an exposure must declare at least one target.

Contracts are referenced by identity and, optionally, by content

Section titled “Contracts are referenced by identity and, optionally, by content”

A target’s contract names the contract by name:tag. Contract membership is frozen per tag, so that identity says which members the target can select. The sha256 is optional. It is an author pin with the meaning pins have everywhere in Peppy:

  • Present: the contract resolved for the exposure must fingerprint to exactly that value. A launch or a repository check that resolves different bytes refuses, naming the pinned fingerprint and the resolved one.
  • Absent: the launch pins the contract bytes it resolves, and the exposure is validated against those. The exposure follows the contract as its repository evolves.

The value is the SHA-256 hash of the contract document’s exact bytes: sha256sum contracts/rgb_camera.json5. Pin when the schemas you published to clients must not change under you without a deliberate republication of the exposure.

A daemon target names daemon: { name, tag } instead of a contract. Its members are functions of the peppy daemon that started the server, for example the addition of a robot to the stack. A daemon interface is a document in the contract format that is compiled into peppy. No node implements it, and nothing on the messaging layer answers it: the server calls its own daemon.

exposures/framework_controls.json5
// An exposure whose one target names a daemon interface: `stack_copies:v1`,
// which the peppy daemon that started the server serves. No node implements
// it, so the target takes no `links`: the launcher gives it a scope, the
// robot options a client may add, the features of each, and the most robots
// of them on the stack.
{
peppy_schema: "mcp_exposure/v1",
manifest: { name: "framework_controls", tag: "v1" },
server: {
title: "Robot stack (peppy framework)",
instructions: "Add robots to the stack and remove them. stack.list reports the robot options this stack offers with their features, the machines of the stack and the robots it can remove. A robot and each of its features run on a machine: a peppy daemon, named by its core node name.",
},
call_record: {
tool: "stack.recent_calls",
description: "List the last calls of this endpoint that could change the stack, newest first.",
keep: 200,
},
targets: {
stack: {
// A daemon interface is compiled into peppy and is never pinned:
// `daemon` takes a name and a tag, and no `sha256`.
daemon: { name: "stack_copies", tag: "v1" },
services: [
{
member: "list",
tool: "stack.list",
description: "Report the robot options this stack can add with their features, the machines of the stack and this stack's machine, the robots of these options on the stack with their features and machines, the most robots it holds, and the addition or removal of a robot that runs now.",
operation: "read_only",
deadline_ms: 5000,
},
],
actions: [
{
member: "join",
tool: "stack.join",
description: "Add a robot of option to the stack under name on the machine core_node, with the features it names, each on its own machine, and wait until its software runs on each of its machines.",
operation: "long_running",
progress_timeout_ms: 660000,
},
{
member: "remove",
tool: "stack.remove",
description: "Remove a robot from the stack, and wait until its software has stopped.",
operation: "long_running",
progress_timeout_ms: 660000,
},
],
},
},
}
  • Never pinned. A daemon target takes no sha256. peppy serves one tag of each interface, the tag of its release, and no older one. A change of members, or of what a member means, takes a new tag, so a document that names an interface ships with the peppy release that serves its tag.
  • One grammar. Members, public names, descriptions and every policy are the same as on a contract target. The member rules also apply: a member that the interface does not declare, a member selected twice, a target with no member and a public name used twice are refused.
  • Fixed surface only. A daemon target sits on a fixed surface. On a per-robot surface every tool is routed by robot, so a daemon target belongs in an exposure without robots.
  • No slot. The derived node does not consume or link anything for a daemon target. The launcher gives the target a scope instead of a link: what a client of the target may do. A deployment whose exposures hold only daemon targets is valid.
  • The coordinator. An instance that serves a daemon target runs on the coordinator, the daemon that the launch was sent to: it reads and changes the stack of that daemon.
InterfaceMembersWhat a client does
stack_copies:v1service list, actions join and removeLists, adds and removes the copies of the running launch, within the options and the features the scope names, on the machines of the federation

A machine is the core node name of a peppy daemon: the name that presence lists and that --place NAME@CORE_NODE takes. The members of stack_copies:v1:

MemberRequestFeedbackResponse or result
list (service)noneoptions: each { option, model, description, features } of the scope, features each { feature, description }, empty when the option has none. core_nodes: this machine and every live daemon of the same workspace or project, each once, in name order. this_core_node: the machine of this stack, the coordinator. copies: each { name, option, core_node, features }, the copies of the scope’s options on the stack, in name order: core_node is the copy’s machine, which runs its base, and features holds each feature of the option that the copy holds, as { feature, core_node } with the machine of its part. max_copies: the most copies of the scope’s options on the stack. change: { action, name, option }, the addition (join) or the removal (remove) of a copy of a scoped option that runs now; absent while none runs
join (action)name, option, core_node, features: each { feature, core_node }step (launch, add, build or run) and line, one message per line of the join’s logsuccess, and a message that says why and names the log files when the join failed
remove (action)namestep and line, one message per line of the removal’s logsuccess and message

join adds a copy of option under name on the machine core_node, with the features it names, each part on its own machine, as peppy stack join OPTION:NAME@CORE_NODE does with one --with axis=option@CORE_NODE word for each feature, the axis and the option that the scope gives that feature: the option’s entry in the launcher decides the rest of the copy’s selection. Every field is required, and features is empty for the copy of the launcher’s entry. So each item of copies is a valid join input, and for a copy that join added, it adds the same copy on the same machines. remove stops and removes a copy, as peppy stack remove NAME does.

The launcher gives each daemon target its scope with daemon_scopes on the instance, or with a set_daemon_scopes adjustment. The launch parses every scope into the type of its interface before anything starts. The scope of a stack_copies target is the value under its target’s name:

launchers/mcp_framework_controls.json5
// Deploying an exposure with a daemon target: the robots run as copies, and
// the framework's endpoint lets a client add and remove them.
//
// peppy stack launch mcp_framework_controls
{
peppy_schema: "launcher/v1",
components: [
{
name: "robot",
cardinality: "zero_or_more",
options: {
// Its fragment declares the own axis `brain`, whose option
// `ai_brain_vla` deploys the brain's instance.
openarm_sim: "robots/fragments/openarm_sim.json5",
so101_sim: "robots/fragments/so101_sim.json5",
},
},
],
deployments: [
{
source: { exposures: ["framework_controls:v1"] },
instances: [
{
instance_id: "framework_controls_inst",
arguments: { port: 8903 },
// The daemon target `stack` takes no link. It takes a scope,
// keyed by the target's name. The instance has no `core_node`: an
// instance that serves a daemon target runs on the coordinator.
daemon_scopes: {
stack: {
max_copies: 4,
options: [
{
option: "openarm_sim",
// The robot model a robot of the option is, which a
// client reads before it adds one.
model: "openarm_v2",
description: "A simulated OpenArm v2 standing on the floor: two arms with grippers",
// A feature selects one option of one own axis of the
// option, here `brain=ai_brain_vla`. A client adds a robot
// with it, and names the machine of its part.
features: [
{
feature: "ai_brain",
axis: "brain",
option: "ai_brain_vla",
description: "An AI brain that finds items by words, then grabs and places them",
},
],
},
{
option: "so101_sim",
model: "so101",
description: "A simulated SO-101 clamped on the edge of a table: one arm with a gripper",
},
],
},
},
},
],
},
],
}
  • options names at least one option, each option once. option is an option of an axis of the launcher that runs as copies. model is the id of the robot model that a copy of the option is, for example openarm_v2: letters, digits, _ and -. A client reads it before it adds a copy, for example to show a picture of that model. description is one line of at most 200 characters that says what a copy of the option is.
  • features is optional: leave it out for an option with no feature, because features: [] is refused. It lists at most 6 features. A feature is one choice that the author offers on a copy of the option: feature is its name, axis and option are the one option of one own axis of the scoped option that a copy with the feature holds, and description is one line of at most 200 characters that says what the feature adds. Two features of one option with the same name, or on the same axis, are refused.
  • The description of an option says what the robot is, and the description of a feature says what it adds.
  • max_copies is a number from 1 to 65535: the most copies of these options on the stack.

The launch and peppy stack resolve also hold a stack_copies scope against the launcher and the stack selection, in three steps. A step runs only when the steps before it hold:

  1. Each option is an option of an axis that runs as copies. In a launcher that declares no such axis, a flat launcher included, the scope gets one refusal that says so.
  2. Each feature selects an own axis of its option and an option of that axis, and that option brings at least one instance, because a feature’s part can run on another machine. This step composes nothing, and each refusal names what the option offers: “feature ai_brain of option so101_sim selects brain=ai_brain_vla, but so101_sim has no own axis brain; its own axes: robot_commander, recorder, camera_rig”.
  3. Every feature set of each option (no feature, each feature, each combination of features) composes as a join over the bare stack that the selection resolves, never over the copies the launch starts. A refusal names the option and the features of the set, then the reason of the composition: “option openarm_sim with the features ai_brain: …”. The copy with no feature must hold none of the option’s features: a feature that the option’s launcher entry already selects adds nothing, and is refused. The nodes of these trial copies are the joinable nodes that the launch builds on the coordinator before any join (see Building the nodes a join brings).

The verdict depends only on the launcher and the stack selection, which a join does not change, so a join holds each scope to the rules of its instance and not to these steps.

The server narrows the published schemas of a daemon target with its scope. For stack_copies, join.option takes only the options of the scope; join.name, join.core_node, the core_node of each feature item and remove.name take only a core node name: 1 to 63 letters, digits, _ or -, and not self, the rule that the daemon and the CLI apply to a copy and to a machine; and the feature of a feature item takes only a feature of the scope, of any of its options. In a scope with no feature, features takes only an empty array. No schema lists the machines, because the machines of the federation change while the endpoint runs: a machine that joins the federation after the launch can take a copy or a feature. The endpoint refuses input outside the narrowed schema before the call reaches the daemon, and the call record writes the call as refused. peppy mcp catalog prints the schemas of the interface without the narrowing, because the scope belongs to an instance of the server and not to the exposure.

The endpoint calls the daemon that started it, through the same goal path as peppy stack join and peppy stack remove.

  • list answers the options of the scope with their models, their descriptions and their features, core_nodes, this_core_node, the copies whose option is in the scope in name order, and max_copies, within the deadline of the tool. It reads the stack and presence at the same time, on every call, so a machine that starts after the launch is in the next core_nodes, and a machine that stops is not. A presence read that fails makes list fail: “the live machines of the federation could not be read: …”.
  • Each copy in copies names its machine, and the features of its option that its selections hold, in the scope’s order, each with the machine of its part, which is the copy’s machine when the part runs with the copy. A part on another machine that no feature of the scope selects is not listed. A copy that peppy stack join or --join added is listed the same way, and its item can then make another copy than the one listed, when a word of the command line selects or places a part that no feature of the scope names. A copy whose machine, or the machine of one of its features, is not in core_nodes has software on a machine that is not live now.
  • While the daemon adds or removes a copy of an option of the scope, from any client, list also answers that change: "change": { "action": "join", "name": "bravo", "option": "so101_sim" }. A copy that joins is in copies only once its join ends, so change is where a client sees it in the meantime, also after a call that stopped waiting. While the daemon has not yet admitted or refused a join of the endpoint, list waits for that answer, so the join is in change from the moment the endpoint takes it.
  • join first parses its input: the name as a copy name, the option as an option of the scope, each machine as a core node name, and each feature as a feature of the option, named once. It refuses a feature that the option does not offer (“ai_brain is not a feature of option so101_sim, which has no feature”, or with the list of the option’s features) and a feature named twice (“features names feature ai_brain twice; name each feature once”).
  • join then reads the copies of the stack. It refuses a name that a copy of any option has (“a copy bravo is already on the stack; choose another name”), and refuses when the stack holds max_copies copies of the scope’s options, naming them. It then sends the join of the option under the name on its machine, with the word axis=option of each feature on the machine of its part, as peppy stack join OPTION:NAME@CORE_NODE --with axis=option@CORE_NODE does: a machine equal to this_core_node travels as the coordinator, and a feature on the copy’s machine names no machine, so its part runs with the copy’s base. The join carries no argument and no environment, and it runs under the default budgets of the CLI. The robot runs with the environment that its launcher entries declare.
  • The launch builds on the coordinator every node that a join of a scoped option can bring (see Building the nodes a join brings), so a join on this_core_node adds and starts nodes and builds none. A part that a join places on another machine builds on that machine when the join runs, which can take minutes the first time, and so do the nodes of every join after a launch with --no-prebuild.
  • The endpoint reads no presence for a join. The daemon reads it when it plans a join that names another machine, and refuses a machine that is not live before any machine changes. The join then ends with success: false, and its message lists the live machines: “these core nodes are not live on the federation: cn-gpu-box. Live right now: cn-desk. …”. A machine in core_nodes can still refuse a join, for example one that runs another peppy version, that another launch holds, or that holds instances outside this launch, and a node of a filesystem repository runs on the coordinator alone (see Federation). These joins end the same way, with the daemon’s reason in message.
  • When the daemon accepts the join, the first progress message says so (launch: the daemon accepted the addition of bravo (so101_sim)), and each line of the join’s log follows as <step>: <line>, where the step is launch, add, build or run. The lines of a starting instance are in it. A join that succeeds ends with success: true and “bravo (so101_sim) is on the stack”. A join that fails is undone, and ends with success: false and a message that says why and names the log files: the log of the join, then the log of the node phase that failed. When the undo cannot clean up, the message says that the copy remains listed. A refusal that the daemon gives after it accepts the join, for example a copy of that name that joined since the read of the stack, ends the same way.
  • The endpoint runs one join at a time, from its read of the stack until the daemon’s work ends, also after the call ended. Another join in that time is refused with the daemon’s busy text: “a stack or node operation is in progress on this daemon; wait for it to finish”.
  • remove reads the copies of the stack, and refuses a name that is not a copy of an option of the scope (“no copy zulu of the options openarm_sim and so101_sim on the stack”). It then sends the removal, which reports each instance that it stops, the last started first, on each machine of the copy, and what it leaves on a machine where nothing stops (see copies). A removal that succeeds ends with success: true and “bravo is off the stack”. A removal that the daemon refuses after it accepts it, for example of a copy that the stack links to, ends with success: false. remove waits for no other call of the endpoint: the daemon refuses a removal while another stack change runs.
  • A cancel, or a closed call, stops the wait and not the change: the call ends at once as cancelled, with success: false and “stopped waiting; the addition of bravo continues on the stack”. The endpoint sends no cancel to the daemon, because a stack change ignores one, and follows the change to its end. A cancel that comes before the daemon answers the goal waits for that answer, so a goal that the daemon refuses ends refused. Call list before you add or remove again: its change reports the change until it ends.
  • When the daemon sends nothing for one progress window of the document, the call fails with “the daemon sent nothing for 660 s; the addition of bravo can still be running”. The endpoint sends no cancel and follows the change to its end. A change that ends without a result, for example because the daemon is gone, fails the call and names the log of the change.

The call record writes:

Calloutcomesuccess
Input outside the narrowed schemarefused
A refusal before the change: a feature that the option does not offer or that the input names twice, a name in use, the limit, a second join, a name that is no copy of the scope, or the daemon’s refusal of the goal, its busy text includedfailed
A join or a removal that ran to its end, a join refused for a machine that is not live includedcompletedtrue or false
A call that stopped waitingcancelled
A silent daemon, or a change with no resultfailed

resource, tool and picture.tool set the names that clients see. You choose these names, and they do not need to match the internal member names. All public names share one namespace across the whole exposure. A resource and a tool cannot share a name, even across targets. Names must be 1 to 128 characters long and use only ASCII letters, digits, _, -, or .. A . cannot be the first or last character or appear next to another ..

A resource’s URI is derived from its name as peppy://resource/<name>.

Each exposure is its own endpoint with its own catalog, so two exposures served by one process may reuse a public name without conflict.

Policies limit how each published member can be used. You declare them for each member, and the running server enforces them.

FieldRequiredWhat it does
freshness.max_age_msyesTreat a snapshot older than this as stale instead of returning it as current
update.max_hzyesLimit how often the snapshot refreshes and notifies subscribers. Faster messages are dropped before decoding or transcoding
representationnoInterpret the message as an image and publish its frame in a codec, as a blob apart from the rest of the message (see below)
max_result_bytesconditionalCap on the snapshot content after representation runs: the serialized document plus, under a representation, the base64 text of the blob
on_oversizeconditionalWhat to do when the content exceeds that cap: downscale (re-encode small enough to fit) or reject (report the read as failed)
picturenoPublish the frame through a picture tool as well. Requires a jpeg representation

The message format determines whether the size policy is required:

  • A payload with a static maximum size contains only fixed-size scalars, fixed-length arrays, or nested objects made from those types. max_result_bytes is optional. If you set it, it must be at least the maximum possible size. Do not set on_oversize, because the payload cannot exceed that limit.
  • A payload with no static maximum size contains a string, bytes, or a variable-length array, such as a camera frame. You must set both max_result_bytes and on_oversize to limit what a read can return.

An image representation names members of the topic’s own message format by role:

representation: {
image: "jpeg", // `png16` for a 16-bit single-channel frame, or `raw`, which passes the frame bytes through untouched
quality: 80, // `jpeg` only; `png16` is lossless and `raw` has no encode step
fields: {
data: "frame", // `bytes` or an array of `u8`
encoding: "encoding", // `string`
width: "width", // `u8`, `u16`, or `u32`
height: "height", // `u8`, `u16`, or `u32`
},
depth_range: { near: 100, far: 10000 }, // `jpeg` only; how a `z16` depth frame renders
}

All four fields must refer to required, non-$optional top-level members of the correct type. The runtime needs all four values to interpret a frame. jpeg transcodes rgb8 and bgr8 frames, and z16 depth frames under a depth_range; png16 transcodes frames of one unsigned 16-bit little-endian sample per pixel, labeled z16, mono16 or 16UC1, such as a depth map, and writes the frame as a 16-bit PNG under the encoding png. A frame already labeled jpeg or mjpeg under jpeg passes through untouched; a png frame under png16 passes through once it decodes as 16-bit single-channel, and any other PNG is refused. A 16-bit frame keeps one source sample per output pixel.

A jpeg representation renders rgb8 and bgr8 frames in color, and 16-bit depth frames, labelled z16, mono16 or 16UC1 (one little-endian u16 reading per pixel, 0 for a pixel with no reading) as a grayscale picture. depth_range sets the readings that picture spans, in the frame’s own counts, so one shade means one distance on every frame a client reads: a reading at near or nearer renders white, one at far or farther black, readings between them in linear proportion, and 0 black. near must be above 0 and below far. A z16 frame that arrives on a representation without a depth_range is refused, and a depth_range on a raw representation is a parse error.

A snapshot under a representation has two parts, and a read serves each as a content of its own:

  • The document is the message as JSON, without the member fields.data names. After a transcode it carries the encoding label and the dimensions of the published frame.
  • The blob is the frame in the codec of the representation, under the MIME type of that codec:
representation.imageBlob MIME type
jpegimage/jpeg
png16image/png
rawapplication/octet-stream

A snapshot of a topic with no representation is a document alone: the whole message as JSON.

A target may select one topic member more than once, each entry with its own resource name, representation and policies, and each keeping its own snapshot: a depth frame reads as a jpeg picture a model judges distance from and as a lossless png16 frame a program decodes.

picture publishes the frame of a topic through a tool, beside the resource, for a model that looks on its own: the image of a tool result is the form in which a client hands a picture to the model.

picture: {
tool: "front_camera.look", // a public name, in the one namespace of the exposure
description: "Look through the front-facing camera: the latest frame as a picture.",
}

The topic must carry representation.image: "jpeg": a png16 or a raw frame is data for a program, not a picture for a model. The tool answers inside the call, from the snapshot a read of the topic’s resource serves, under the same freshness policy. It sends nothing to the Peppy graph. On a fixed surface it takes no argument, and on a per-robot surface it takes the routing arguments of its target.

FieldRequiredWhat it does
operationyesread_only or mutating, advertised to the client. Long-running work is an action, so there is no third value
deadline_msyesHow long the call waits before it comes back as a tool error
restrictnoInclusive min/max bounds narrowing numeric request fields
representationnoInterpret the response as an image and answer its frame as a picture, apart from the rest of the response (see below)
max_result_bytesnoCap on the serialized tool result: the serialized document plus, under a representation, the base64 text of the frame. A larger response is a tool error
on_oversizenoWhat to do when the result exceeds that cap: downscale (re-encode the frame small enough to fit; needs a jpeg or png16 representation) or reject (a tool error). Needs max_result_bytes

A service representation is the topic representation, on the members of the service’s response: fields.data names the response member that carries the frame, and the other three its encoding label and dimensions, all of the same types a topic needs. The tool answers as a picture tool does: the frame as an image block in the codec of the representation, and the rest of the response as the text and the structured content. Its published output schema is the schema of that document: the response without the member the frame is taken from. A response whose frame is empty carries no picture and is answered as the document alone, so a service that refuses (success: false with a message) reaches the client with its words. A frame that does not decode is a tool error.

{
member: "render_view",
tool: "scene.look",
description: "Look at the simulated world from a free viewpoint.",
operation: "read_only",
deadline_ms: 10000,
representation: {
image: "jpeg",
quality: 80,
fields: { data: "frame", encoding: "encoding", width: "width", height: "height" },
},
max_result_bytes: 524288,
on_oversize: "downscale",
}

restrict narrows a numeric range without changing the contract’s type. Each key names a top-level member of the request format. Peppy adds the bounds to the published input schema as minimum and maximum, so clients can see the allowed range. The endpoint rejects values outside that range before sending anything to the Peppy graph.

Each bound must fit the member’s type. For example, validation rejects min: -200 for an i8. You cannot restrict a u64 or i64 member because its public schema uses a decimal string instead of a JSON number. Use a narrower type in the contract if you need numeric bounds.

FieldRequiredWhat it does
operationyeslong_running, the only value; actions are long-running by definition
deadline_msone of the twoWhole-goal deadline: the longest time the goal may run. The advertised task TTL is this plus a short grace window
progress_timeout_msone of the twoThe longest time the goal may go without a sign of progress. The goal may run for as long as it makes progress, up to one day as a task. The action must declare a feedback_topic
safety_sensitivenoAdvertises that the call changes the physical world. Defaults to false
confirmation_requirednoThe task waits in input_required and sends no goal until the client confirms through tasks/update. A client without the tasks extension cannot call the tool. Cannot go with progress_timeout_ms. Defaults to false

An action declares exactly one of deadline_ms and progress_timeout_ms.

deadline_ms bounds the whole goal. Feedback does not extend it. When it passes, the call or the task fails with a message that names the deadline, and the provider gets no cancel: the goal keeps running.

progress_timeout_ms bounds the silence, not the work. Use it for a goal whose length depends on something the robot does not control, such as assets that download over a slow link. The signs of progress are the admission of the goal, each feedback message, and the end of the feedback stream when the goal settles. Each sign starts a new window, and the request for the result after the end of the stream gets one window. When a window goes by without a sign of progress, the endpoint sends the provider one cancel and continues to follow the goal under the same rule:

  • A goal that completes returns its result, as usual.
  • A goal that ends cancelled returns the provider’s result as a cancelled goal, with the message the action was cancelled: no progress within <N> ms.
  • A goal that stays silent for one more window fails with no progress within <N> ms; a cancel was sent and the provider did not answer it.

The provider’s reply to the cancel is read too. A reply that the goal had already ended means the end of its feedback stream did not come through: the endpoint reads the result at once and returns it, or fails with the goal ended, but its result is no longer readable when the provider no longer keeps it. A reply that the provider does not know the goal fails with no progress within <N> ms; a cancel was sent and the provider does not know the goal. A cancel that fails, for example because no reply came within one window, fails with no progress within <N> ms; a cancel was sent and failed: <error>, and the endpoint logs it as a warning, because the goal can still run on the provider. The cancel the endpoint forwards for the client gets the same replies, and the same messages without the no progress within <N> ms; part.

A tool of a daemon target follows a different rule, because a stack change ignores a cancel: after one window without a sign of progress, the call fails, the endpoint sends no cancel, and it follows the change to its end (see What the tools of stack_copies do).

A feedback message that does not decode is logged and not shown to the client. It is still a message the provider sent for the goal, so it counts as a sign of progress.

The bound relies on the provider: it publishes a feedback message each time its progress changes, and none while nothing changes. A message sent on a timer while nothing moves would hide a goal that is stuck. The wait for a confirmation is no progress of the goal, and nothing would bound it, so an action cannot declare progress_timeout_ms together with confirmation_required.

Under both bounds, the first wait is for the provider to admit the goal, and it is bounded too: by deadline_ms, or by one window of progress_timeout_ms. When that wait runs out, the call or the task fails and the provider gets no cancel, because the endpoint has no handle on a goal that the provider did not admit. This is a limit of both bounds: a provider that admits the goal after that runs it with nothing following it, until the goal ends or the provider cancels it for a reason of its own.

An exposure can keep a record of the state-changing calls its endpoint takes, and publish it through a tool:

// Beside `server` and `targets`.
call_record: {
tool: "robot.recent_calls", // a public name, in the one namespace of the exposure
description: "The last state-changing calls of this endpoint, newest first. Read `outcome` and `success` together: a completed call with `success: false` ran to its end and reports a failure.",
keep: 200, // how many calls the endpoint keeps, 1 to 1000
},

The endpoint records every call of a tool that is not read-only (a mutating service, and every action, whether it runs inside the call or as a task) and keeps the last keep of them in memory for the life of the process. The tool takes no argument, is listed read-only, leaves no entry of its own, and answers { calls: [...] }, newest first. Each entry carries:

FieldWhat it says
started_atWhen the call started, RFC 3339 on the wall clock of the host, also on an endpoint whose freshness runs on simulated time
clientThe name and version the client declared as its implementation, or null when its request named none
toolThe tool called
argumentsThe arguments as sent, when their JSON is 1024 bytes or fewer; null otherwise
arguments_bytesThe size of the arguments’ JSON
outcomerunning, completed, failed, cancelled or refused
successThe success member of the result of a completed call that has one, else null
messageThe result’s message member of a completed call; for the other outcomes, what ended the call
duration_msHow long the call ran, once it ended

A task’s entry is running until the task ends. A call ends completed when the provider answered, whatever its answer says; failed when the call did not reach an answer (a deadline, a provider error, a goal that ended rejected or abandoned); cancelled when the goal ended cancelled, the task was cancelled, or its confirmation was declined; refused when the call never reached the provider: its arguments failed the published schema, it named a robot or a member that is not there, it needed the tasks extension the client did not declare, or no provider was bound.

The record holds the calls of MCP clients on that endpoint. A motion that a teleoperation or another node of the stack commanded is not in it. When a client finds the robot in a state it did not command, the record says whether another client of the endpoint did it, and, when no entry accounts for it, that no client did.

You do not define request and response shapes in the exposure. Peppy derives them from the contract’s message_format declarations. The catalog records the version of the mapping used to create the schemas. The schema of a resource with a representation is the schema of its document: the message without the member the blob carries. It is also the output schema of the topic’s picture tool, and a service with a representation publishes the schema of its document the same way.

Most types map directly to JSON. These are the important mapping rules, including the types that JSON cannot represent directly:

message_formatPublished JSON SchemaWhy
bool, string, f32, f64boolean, string, numberDirect
u8…u32, i8…i32integer with minimum/maximumThe type’s own range is published
u64, i64string with a canonical decimal patternJSON numbers lose precision above 253
timestring, format: "date-time"RFC 3339, fractional seconds carrying full nanosecond precision
bytes, array of u8string, contentEncoding: "base64"Identical on the wire, so they share one rendering. A fixed byte length pins the exact base64 length
Other arraysarray with items; fixed length pins minItems/maxItemsDirect
Nested objectsobject with every property required$optional is legal only at the top level
$optional top-level fieldOmitted from requiredDirect

Public property names use the DSL’s snake_case spelling. The internal wire encoding uses lowerCamelCase instead. Every derived object schema sets additionalProperties: false.

An exposure is checked where it is published, by the repository check, and again by every launch that serves it. Both read the contracts through the local repository caches, so run peppy repo refresh on the machine first, and both read the daemon interfaces from the peppy that runs them. Neither check needs a running daemon.

Terminal window
peppy repo index . --check --validate-mcp-exposures # the index, then every exposure against its contracts
peppy mcp catalog camera_and_recording:v1 # the catalog an endpoint for the exposure advertises

peppy repo index --check --validate-mcp-exposures validates every exposure the repository’s index lists against the contracts and the daemon interfaces it references and reports every violation of every exposure at once, so you fix a document in one pass. A contract the caches cannot resolve is an error naming that contract, never a pass. A file that declares the exposure schema but does not parse as one (a document claiming a public name twice, say) is listed by no index, so the check reports it by path rather than letting it pass unseen. Run the check in the repository’s CI, as mcp-hub does: a hub cannot then merge an exposure that a launch would refuse.

2 exposures do not validate:
- mcp_exposure `camera_and_recording:v1` (exposures/camera_and_recording.json5):
- target `front_camera` selects service member `set_brightnes`, but contract `rgb_camera:v1` declares no such service (declared services: `video_stream_info`, `set_brightness`)
- mcp_exposure `recording_only:v1` (exposures/recording_only.json5):
- contract `episode_recording:v1` not in contract cache; run `peppy repo refresh`

peppy mcp catalog <name:tag> prints the catalog the server derives for one exposure: every resource, tool, task, and picture tool with its public name, description, policies, and derived JSON Schemas, the call record when the exposure declares one, plus the identity the endpoint advertises, the contract slots its contract targets become (contracts), and the interface each daemon target names (daemon_targets). It is what a running endpoint answers through discovery and the list methods, so it is the thing to read when you want to see what a client will see. For a daemon target it prints the schemas of the interface without the narrowing of a scope: a running endpoint narrows them with the scope its launcher gives the target, which belongs to the instance and not to the exposure. The catalog is derived on demand and is never committed.

{
"bundle_format": 1,
"schema_mapping_version": 1,
"exposure": { "name": "camera_and_recording", "tag": "v1" },
"server": {
"title": "OpenArm camera and recording",
"instructions": "Observe the front camera and record teleoperation episodes."
},
"contracts": [
{ "name": "rgb_camera", "tag": "v1", "sha256": "44527ab0…fb736ab", "link_id": "front_camera" },
{ "name": "episode_recording", "tag": "v1", "sha256": "a4cfd6ec…f177c0d2", "link_id": "recorder" }
],
"resources": [ { "name": "front_camera.latest_frame", "uri": "peppy://resource/front_camera.latest_frame", "…": "…" } ],
"tools": [ { "name": "front_camera.info", "…": "…" }, { "name": "front_camera.set_brightness", "…": "…" } ],
"tasks": [ { "name": "recorder.record_episode", "…": "…" } ],
"pictures": [ { "name": "front_camera.look", "resource": "front_camera.latest_frame", "…": "…" } ]
}

A launcher deploys exposures with source: { exposures: [...] }, listing each as "<name>:<tag>". The deployment takes one argument, port, and one links entry per contract target of every exposure it lists. Each link connects a logical role to a running instance. A per-robot surface takes no links: the launcher’s adjustments under each robot’s option fill its targets. A daemon target takes no link either: it takes a scope.

launchers/mcp_camera_and_recording.json5
// Deploying an MCP exposure: the providers by instance, and the server built
// into peppy serving the exposure, its logical targets filled through
// `links`.
//
// peppy stack launch mcp_camera_and_recording
{
peppy_schema: "launcher/v1",
deployments: [
{
source: { name: "uvc_camera:v1" },
instances: [{ instance_id: "front_cam_inst" }],
},
{
source: { name: "episode_recorder:v1" },
instances: [{ instance_id: "recorder_inst" }],
},
{
// The exposures to serve, each as `<name>:<tag>`. The daemon derives
// the server from these documents and the contracts they reference,
// and runs one `peppy mcp serve` process for the list: nothing is
// generated or built. Its node is named after the set, here
// `mcp_camera_and_recording_v1:builtin`.
source: { exposures: ["camera_and_recording:v1"] },
instances: [
{
instance_id: "mcp_server",
// The only argument the server takes. It binds `127.0.0.1:<port>`
// and serves each exposure at `/<name>/<tag>/mcp`, so this one is
// reached at http://127.0.0.1:8900/camera_and_recording/v1/mcp.
arguments: { port: 8900 },
// One entry per target in the exposure document: the launcher
// binds each logical target to a concrete running instance. This is
// where "the front camera" stops being a role and becomes a robot.
links: {
front_camera: "front_cam_inst",
recorder: "recorder_inst",
},
},
],
},
],
}
Terminal window
peppy stack launch mcp_camera_and_recording

The daemon resolves each exposure through the machine’s repository caches, then every contract the exposures reference, validates every exposure against those contracts and the daemon interfaces it serves, and derives the server’s node from the result: its identity, its manifest (one contract slot per contract target, consuming exactly the selected members), and the catalog of each endpoint. Nothing is added or built. The node lands in the stack ready to run, with the installed peppy binary as its artifact, and its instance runs peppy mcp serve. You never run that command yourself: the daemon hands it the pinned documents through the environment, and run by hand it refuses, naming PEPPY_MCP_SERVE_SPEC. The derived manifest and the documents the server reads are written under ~/.peppy/built_in/<node name>/. On Linux, replacing the peppy binary on disk while the daemon runs (an upgrade, a rebuild) does not strand it: the daemon keeps serving built-in nodes from its own running image, warns in the add log that the file was replaced, and picks up the new binary when it restarts.

The node’s identity is derived from the exposures it serves, so a relaunch, a peer, and a reader of peppy stack list all name it the same way: mcp followed by _<name>_<tag> for each exposure in name-then-tag order, at the tag builtin. The launcher above registers mcp_camera_and_recording_v1:builtin.

The instance prefers 127.0.0.1:<port> (default 8900) and serves each exposure at its own path:

http://127.0.0.1:8900/<exposure name>/<exposure tag>/mcp

Every other path returns 404, a bare /mcp included. Point a client at http://127.0.0.1:8900/camera_and_recording/v1/mcp.

A deployment of an exposure with a daemon target gives the target its scope, keyed by the target’s name, as the launcher of Scopes does.

The scope reaches the server with the runtime configuration of its instance, and the server parses it when it starts. A launch and a join check every scope before they start the server. A server that starts without a scope for one of its daemon targets, with a scope that does not parse, or with a scope key that names no daemon target, for example under peppy node run of its node, serves nothing and stops. Its run log names each daemon target that lacks a scope and says how to give one: in the instance’s daemon_scopes, or with a set_daemon_scopes adjustment on the instance.

port is a preference. A server that finds the port free takes it. A server that finds it held by another process takes a port the operating system picks on the same loopback address, says so in its run log with a warning naming both ports, and announces the port it holds. Only a held port falls back: any other bind failure stops the process, its run log says cannot bind 127.0.0.1:<port>, and the launch fails naming that instance. The URL the server ended up on is never a guess: the launch or the join that started it prints it under MCP endpoints:, and peppy stack list shows it for as long as the instance runs.

The derived manifest declares one entry under execution.endpoints per exposure, labelled <name>_<tag> (the tag spelled as generated code spells it, - becoming _), of kind mcp, described by the exposure’s title. The server announces each one when it binds, and the daemon reports the URL under that label. The launch ends with them under MCP endpoints:; the server binds loopback only, so each exposure has exactly one URL:

MCP endpoints:
mcp_server (mcp_camera_and_recording_v1:builtin) @cn-sweet-edison
camera_and_recording_v1 http://127.0.0.1:8900/camera_and_recording/v1/mcp
Launch completed successfully

An exposure that declares robots is a per-robot surface: one endpoint for every robot of the stack, whatever their models, where every call names its robot. Its targets are the contracts a robot may bring, and each robot of the stack fills the targets its own instances implement.

{
peppy_schema: "mcp_exposure/v1",
manifest: { name: "robot_control", tag: "v1" },
server: { title: "Robots", instructions: "Call robot.list first; every other tool names a robot it lists." },
robots: {
list: { tool: "robot.list", description: "The robots of the stack, with what each one fills." },
describe: {
identity: { target: "identity", service: "get_identity" },
limbs: { target: "limb_state", service: "get_limbs" },
},
},
targets: {
identity: {
contract: { name: "robot_identity", tag: "v1" },
services: [
{ member: "get_identity", tool: "robot.get_identity", description: "Who the robot is.", operation: "read_only", deadline_ms: 2000 },
],
},
limb_state: {
contract: { name: "limb_state", tag: "v1" },
topics: [
{ member: "limb_states", resource: "robot.limb_state", description: "The robot's limbs.", freshness: { max_age_ms: 2000 }, update: { max_hz: 2 }, max_result_bytes: 65536, on_oversize: "reject" },
],
services: [
{ member: "get_limbs", tool: "robot.get_limbs", description: "The robot's arms and grippers, by name.", operation: "read_only", deadline_ms: 2000 },
],
},
camera: {
contract: { name: "rgb_camera", tag: "v1" },
argument: "camera",
topics: [
{ member: "video_stream", resource: "camera.latest_frame", description: "The camera's latest frame, JPEG encoded.", freshness: { max_age_ms: 2000 }, update: { max_hz: 2 }, representation: { image: "jpeg", fields: { data: "frame", encoding: "encoding", width: "width", height: "height" } }, max_result_bytes: 524288, on_oversize: "downscale", picture: { tool: "camera.look", description: "Look through one of the robot's cameras." } },
],
},
},
}

Every tool of a per-robot surface takes a required robot argument, the robot’s name as the listing tool reports it under robot. A target with an argument of its own is filled any number of times per robot (a camera), and every call on it names the member with that argument; a target without one is filled at most once per robot. A target’s argument cannot be robot. The published input schema of every tool carries the routing arguments as required string properties beside the contract’s own fields, and a contract request field of the same name is refused at validation. robots.describe says what the listing reports of each robot beyond the targets it fills, keyed by the field of the listing entry: the whole response of a service, read through a target the robot fills once and selects as a tool. The listing calls it with the robot alone, so a service taking a request of its own cannot fill a listing field. A describe key cannot be robot, tools, resources, members or notes, the fields every entry carries.

Every target of a per-robot surface is a zero_or_more contract slot the stack’s robots fill: the deployment’s instance takes no links, and the launcher adds each robot’s instances to the targets with add_links on the server’s instance, written under the option that selects the robot, at launch as any adjustment and afterwards as a join grows a running set. The robot’s name is its copy’s name, so only copies fill a per-robot surface, and a target with no argument holds one member per robot. peppy stack launch, peppy stack join and peppy stack resolve hold a launcher to both rules before anything is added, built or started, naming the copy, the instances it bound and the add_links entry to write. A member bound outside a launch, as peppy node run binds one, is left out of the fleet: the server logs it, and a robot’s second member on such a target is named under the robot’s notes, where its calls are refused.

{
source: { exposures: ["robot_control:v1"] },
instances: [{ instance_id: "robot_control_inst", arguments: { port: 8900 } }],
}
// In the launcher, under the option that selects the robot: the robot's own
// instances fill the targets.
options: {
openarm: {
fragments: ["openarm/fragments/openarm.json5"],
adjustments: [
{ target: "robot_control_inst",
add_links: { identity: ["init_inst"], limb_state: ["backbone_inst"] } },
{ target: "robot_control_inst", when: { camera_rig: "cameras" },
add_links: { camera: ["wrist_left", "wrist_right"] } },
],
},
}

The server reads the fleet from the targets’ bound sets on every list and call, so a robot is listed the moment the join that starts it returns and gone the moment its removal returns, with no restart and no effect on the calls, tasks and subscriptions of the other robots. With no robot the endpoint serves an empty fleet, and the first join onto it is accepted.

What a client sees on a per-robot surface:

  • The listing tool takes no arguments and answers { robots: [...] }, one entry per robot, in the order of the document’s targets and of the members the stack binds to each: robot, its name; tools, sorted, the tools that answer for it, each called with its name; resources, sorted, the resources it publishes, each read at peppy://resource/<name>; members, for each target with an argument, the names a call addresses its members by, each the id the robot’s fragment wrote for that instance; notes, what could not be read or served for it and why; and one field per describe key, null when it could not be read, with the reason under notes. A tool’s description is where the model reads what a tool answers; the listing is where it reads which robot answers it.
  • Tools are the union of every target’s tools, picture tools included, fixed for the life of the server and cached as on a fixed surface. A picture tool takes the routing arguments of its target and nothing else: camera.look above takes robot and camera. A call naming a robot that is not on the stack, a target the robot does not fill, or a member the robot does not have is refused before anything reaches the Peppy graph, naming what is there: the robots, the targets the robot fills, or the members it has. A refused member also names the other targets the robot fills with a member of that name, so a camera.look naming chest on a robot whose RGBD camera chest fills depth_camera and not camera says that its chest fills depth_camera.
  • Resources are published per robot and member: alpha/robot.limb_state at peppy://resource/alpha/robot.limb_state for a target filled once, alpha/wrist_left/camera.latest_frame for a camera. resources/list follows the fleet and carries no cache hint, and the server sends notifications/resources/list_changed to listening clients when a robot joins or leaves. A resource of a robot that just joined reads as unavailable until its first snapshot arrives.

One deployment can list several exposures, two tags of the same exposure included. They share one process and one port, and nothing else: each endpoint keeps its own catalog, snapshots, subscriptions, and task handles, so a public name, a subscription, or a task handle on one endpoint is unknown to the others.

Targets with the same name across the listed exposures share one slot when they name the same contract, and the manifest consumes the union of the members they select. One links entry then fills the slot for every exposure using it. Daemon targets with the same name share one scope when they name the same interface. The same target name bound to different sources (two contracts, a contract and a daemon interface, or two daemon interfaces) is refused, naming both exposures and both sources: two exposures sharing a target name must name the same source.

{
source: { exposures: ["camera_and_recording:v1", "camera_only:v2"] },
instances: [
{
instance_id: "mcp_server",
arguments: { port: 8900 },
// `front_camera` is a target of both exposures, bound to `rgb_camera:v1`
// in each, so one link fills it for both endpoints.
links: { front_camera: "front_cam_inst", recorder: "recorder_inst" },
},
],
}

The launch refuses, before anything starts, when:

  • an exposure or a contract it references is not in the caches, or an exposure does not parse;
  • an exposure names a daemon interface, or a tag of it, that this peppy does not serve. The refusal names the tag that this peppy serves;
  • an exposure does not validate against its contracts and daemon interfaces. The report lists every violation of every listed exposure at once;
  • an exposure’s sha256 disagrees with the contract bytes the launch resolved, or two listed exposures pin one contract at different bytes. One deployment carries one content per contract;
  • an exposure is listed twice in one deployment;
  • two deployments list the same set of exposures. They would derive the same node identity, so the error names the set and points at the remedy: to serve one set on several hosts, declare one deployment with one instance per host;
  • a deployment lists a per-robot surface beside a fixed one, or two surfaces sharing a target name its member by different arguments;
  • a links entry names no target of the listed exposures, or a contract target is left without a link, as for any node’s slot;
  • a daemon target has no scope, a scope does not parse into the type of its interface or does not hold against the launcher, or a scope or a link is on the wrong instance or target. See the checks of daemon_scopes;
  • on a per-robot surface, a robot fills a target with no argument twice, or an instance the launcher deploys outside every copy fills a target. peppy stack join refuses the same two before the copy starts.

Any number of instances run on one machine, whatever port each prefers. The first to reach a port holds it and the others take another, so a launcher that needs a known URL for each server names a different port for each, and a joined copy gets one with --set-arguments <instance>.port=<n>. Two servers that start together and prefer one port settle between themselves which of them holds it. Read a server’s run log with peppy node info on the node’s identity.

peppy stack list adds an Instance endpoints table when any instance serves an endpoint: one row per endpoint URL, under the node and instance serving it, with the endpoint’s kind and label.

Instance endpoints
┌──────────────────────────────────────┬────────────┬──────┬─────────────────────────┬───────────────────────────────────────────────────┐
│ NODE │ INSTANCE │ KIND │ LABEL │ ENDPOINT │
├──────────────────────────────────────┼────────────┼──────┼─────────────────────────┼───────────────────────────────────────────────────┤
│ mcp_camera_and_recording_v1:builtin │ mcp_server │ mcp │ camera_and_recording_v1 │ http://127.0.0.1:8900/camera_and_recording/v1/mcp │
└──────────────────────────────────────┴────────────┴──────┴─────────────────────────┴───────────────────────────────────────────────────┘

peppy node info mcp_camera_and_recording_v1:builtin lists the same endpoint under the instance as camera_and_recording_v1 (mcp) http://127.0.0.1:8900/camera_and_recording/v1/mcp.

peppy stack resolve derives the same node from the same caches and holds the deployment’s links to its slots, so a launcher can be checked before it is launched. See Auditing a composed launch.

An exposure deployment is placed like any other: core_node on the instance names the machine it runs on, and the coordinator ships the pinned exposure documents and the contracts they reference, from which the other machine derives the same server. An instance that serves a daemon target is the exception: it runs on the coordinator, so it declares no core_node and belongs to no copy. The federation limits apply: an exposure or a contract resolved from a filesystem repository cannot back an instance placed on another machine. The server reads the messaging layer like any node, so a topic-heavy exposure belongs on the machine that produces the topic.

Discovery. server/discover returns the exposure’s title and instructions, the implementation identity (the exposure’s name at its tag), and 2026-07-28 as the only supported revision. Discovery and tools/list include private caching hints with a one-hour TTL, and so does resources/list on a fixed surface; on a per-robot surface the resource list follows the robots and carries none. The tools do not change while the server is running. To change them, change the exposure document, refresh the repositories, and launch again.

Resources. A read returns a snapshot that passed the policies, as typed contents under the resource’s URI: the document as application/json text and, for a resource with a representation, the blob after it under the MIME type of its codec. resources/list gives a resource with a representation the MIME type of its blob, and every other resource application/json. The ttlMs value of a read says how long the snapshot remains fresh. The resource is unavailable until the first published value arrives. A resource reads live messages only, also on a topic that retains. After the stored value is older than freshness.max_age_ms, reads report it as stale instead of returning old data as current. If the size policy rejects a new snapshot, the server keeps the previous one until it becomes stale.

A read answers with the first message the endpoint receives after the read arrived, whatever update.max_hz admits: a client that changes something and reads at once sees a message from after its change, not the snapshot the last gate interval stored. The wait is bounded by freshness.max_age_ms of wall time; a topic that stays silent that long answers with the stored snapshot when it is still fresh, and with the stale refusal when it is not. Readers that wait together get the same message, so the cost is at most one decode per message. Subscribers keep their rate: a message admitted for a waiting reader alone sends no notifications/resources/updated, and every message the interval admits does.

The guarantee is about when the endpoint received the message, not about when the source captured it. A message carries the source’s stamp, on the source’s clock (in a simulation, simulated time), and a frame that the source captured before a change and delivered after the read arrived still shows the old state. A client that needs the picture after a change reads the stamp.

Tools. The endpoint checks input against the published schema, including restrict bounds, before sending anything to the Peppy graph. It rejects invalid input and unknown tool names. If a service does not respond within deadline_ms, the client receives a readable tool error. A service with a representation answers in the shape of a picture tool: the frame as an image block, the rest of the response as the text and the structured content, and the document alone when the response carries no frame.

Picture tools. A picture tool is listed read-only (readOnlyHint: true, destructiveHint: false) and answers inside the call for every client: it is never a task. Its result carries the snapshot a read of its resource serves at that moment:

{
"content": [
{ "type": "image", "data": "<base64>", "mimeType": "image/jpeg" },
{ "type": "text", "text": "{\"encoding\":\"mjpeg\",\"width\":1280,\"height\":720}" }
],
"structuredContent": { "encoding": "mjpeg", "width": 1280, "height": 720 },
"isError": false
}

While the resource has no snapshot, or a stale one, the tool returns a tool error whose text is the message a read of the resource gives for that state. It waits for the next message as a read does.

Actions. An action-backed tool runs its goal on whichever surface the calling client can drive. The client’s request capabilities decide, per call.

For a client that declares the tasks extension (io.modelcontextprotocol/tasks in its extensions), the call returns a task handle and follows this sequence:

  1. If confirmation_required, the task waits in input_required until the client confirms through tasks/update. No goal is sent before that.
  2. The server sends the goal. Action feedback updates the task’s status message.
  3. tasks/cancel forwards a request to the action’s cooperative cancellation path. A declined confirmation, or a cancel before the client confirms, ends the task as cancelled, and no goal is sent.
  4. On success, the task completes with the structured result. A goal that ends cancelled ends the task as cancelled, and the task’s status message says why, as a cancelled goal describes. A goal that is rejected, abandoned, expired, or past its bound ends as failed with a message saying which event occurred.

Task handles outlive connections. A client that reconnects can continue and observe the same task. They do not outlive the process: stopping the stack ends every task the endpoint was running.

A task under deadline_ms advertises a TTL of the deadline plus a short grace window. A task under progress_timeout_ms advertises a TTL of one day (86400000 ms): the progress window ends a goal that goes silent, and the TTL is the longest a goal that keeps making progress may run as a task. When the TTL passes, the task fails with a generic expiry and the provider gets no cancel. Every task stays readable for one more TTL after it ends.

For a client without the extension, the goal runs inside the call:

  1. The server sends the goal and the call stays open until the goal settles or its bound fails it. Under progress_timeout_ms the endpoint sets no limit on the total time of the call: a goal that keeps making progress runs until it settles or the client closes the call.
  2. Action feedback is sent as notifications/progress under the call’s progress token, in order, before the result. The first notification also opens the response: the endpoint sends the response headers with it. While the server waits for the client to take one notification, new feedback waits in the goal’s feedback buffer, which keeps only the newest messages. A call without a progress token gets no feedback, and nothing arrives on it, not even the response headers, until the goal settles. A client that limits the wait for the headers then limits the whole goal.
  3. Closing the call forwards a request to the action’s cooperative cancellation path.
  4. On success, the call returns the structured result. A goal that ends cancelled returns the tool error of a cancelled goal. A goal that is rejected, abandoned, expired, or past its bound returns a readable tool error saying which event occurred.

A tool with confirmation_required cannot run inside a call: the confirmation is an input request only a task can carry. The endpoint refuses it with error -32021 (Missing Required Client Capability), naming the tool and the extension in the message and the capability in the error data.

A goal ends cancelled when the client cancels it, when the endpoint cancels it because a window of progress_timeout_ms went by without a sign of progress, or when the provider cancels it for a reason of its own, for example because a newer goal replaced it. In each case the provider ends the goal with a result. When the provider cancelled the goal on its own, that result is where it says why.

In a call, the endpoint returns the result in a tool error:

  • isError is true, whatever the result says: the goal did not run to its end.
  • The first text says that the goal was cancelled: the action was cancelled, or the action was cancelled: no progress within <N> ms when the endpoint cancelled it for the silence.
  • structuredContent is the provider’s result, in the tool’s output schema, and the second text is the same result as JSON.

In a task, the task ends cancelled, which carries no result. Its status message is the same first text, followed by : and the provider’s result as JSON.

Validation reports every violation at once, so you can fix the document in one pass. It rejects an exposure when:

  • A selected member does not exist in its contract or exists as a different kind. The error lists the contract’s members and points to the correct section when the name exists elsewhere.
  • A message definition cannot be converted to a public schema.
  • A representation role names a missing, $optional, or wrongly typed member, of a topic’s message or of a service’s response.
  • A restrict bound does not fit its member’s type, names a non-numeric member, or targets a u64/i64.
  • A max_result_bytes is smaller than a payload’s static maximum, or a topic with an unbounded payload omits the size policy.
  • An action with progress_timeout_ms has no feedback_topic in its contract.
  • A contract sha256 does not match the resolved document’s bytes.
  • A daemon target names an interface, or a tag of it, that this peppy does not serve. The error names the tag that this peppy serves, or the interfaces that it serves for an unknown name.

Parsing catches document-level errors before these checks. Examples include:

  • An exposure with no targets or a target that selects no members.
  • A target that names both contract and daemon, or neither.
  • A daemon target with a sha256: a daemon interface is compiled into peppy and is never pinned.
  • A daemon target on a document with robots: every tool of a per-robot surface is routed by robot.
  • A public name claimed twice anywhere in the document, the names of picture tools and of the call record’s tool included, or a service or action member selected twice by one target.
  • A target argument on a document without robots, a target argument of robot, a describe entry naming a target the robot fills any number of times or a service its target does not select as a tool, or an argument name that is not snake_case.
  • A blank title, instructions, or description value.
  • An action that declares both or neither of deadline_ms and progress_timeout_ms, or declares progress_timeout_ms together with confirmation_required.
  • A zero deadline_ms or progress_timeout_ms, or a max_hz value that is not positive and finite.
  • A quality value on a raw or png16 representation.
  • A depth_range on a raw or png16 representation, or one whose near is 0 or not below far.
  • on_oversize without max_result_bytes, on a topic or a service.
  • downscale without a jpeg or png16 representation, on a topic or a service.
  • A call_record whose keep is 0 or above 1000.
  • A picture on a topic whose representation is png16 or raw, or that has no representation.
  • A restrict entry with neither bound, or with min above max.

An exposure keeps everything else private:

  • The MCP endpoint is not a bridge to the messaging layer. It cannot pass through arbitrary messages or access an internal member that the exposure did not select. For example, the running stack includes finish_session, but the exposure does not publish it.
  • The catalog contains exactly the selected members. tools/list and resources/list return only what is in the catalog. The endpoint rejects unknown names.
  • Endpoints served by one process are isolated from each other. A name, a subscription, or a task handle belongs to the endpoint it was created on.
  • The endpoint binds to 127.0.0.1. It is available from another machine only if you deliberately configure network access.