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, 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, at check time and again when the server starts, so the published schemas cannot drift from the contracts behind them.

An exposure draws from contracts. 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
ServiceToolCall it and get the answer in the same request
ActionTool backed by an MCP taskGet a task handle, watch progress, confirm, cancel, and reconnect

The server supports MCP revision 2026-07-28 over Streamable HTTP. It includes the tasks extension (SEP-2663), which action-backed tools need.

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 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.
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",
},
],
// 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 backed by an MCP task: the call returns a
// task handle, feedback drives the status message, and `tasks/cancel`
// forwards to the action's cancel path.
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.
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. The server’s manifest declares one depends_on.contracts slot per 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.

resource and 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 it in a codec (see below)
max_result_bytesconditionalCap on the serialized snapshot content, after representation runs
on_oversizeconditionalWhat to do when the content exceeds that cap: downscale (re-encode small enough to fit) or reject (report the read as failed)

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", // or "raw", which passes the frame bytes through untouched
quality: 80, // `jpeg` only; a `raw` representation 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`
},
}

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. If a frame already uses the requested codec, the server returns it without transcoding. on_oversize: "downscale" requires a jpeg representation because the server must re-encode the image at a smaller size.

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
max_result_bytesnoCap on the serialized tool result; a larger response is a tool error

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_msyesWhole-goal deadline. The advertised task TTL is this plus a short grace window
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. Defaults to false

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.

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
u8u32, i8i32integer 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. 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 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, and task with its public name, description, policies, and derived JSON Schemas, plus the identity the endpoint advertises and the contract slots its targets become. 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. 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", "…": "" } ]
}

A launcher deploys exposures with source: { exposures: [...] }, listing each as "<name>:<tag>". The deployment takes one argument, port, and one links entry per target of every exposure it lists. Each link connects a logical role to a running instance.

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 derives the server’s node from the result: its identity, its manifest (one contract slot per 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 binds 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.

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. The same target name bound to different contracts is refused, naming both exposures and both contracts: two exposures sharing a target name must pin the same contract.

{
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 does not validate against its contracts. 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 links entry names no target of the listed exposures, or a target is left without a link, as for any node’s slot.

Two instances on one machine need distinct ports. A process whose port is taken exits before becoming healthy, its run log says cannot bind 127.0.0.1:<port>, and the launch fails naming that instance. Read the 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.

Instance endpoints
┌──────────────────────────────────────┬────────────┬───────────────────────────────────────────────────┐
│ NODE │ INSTANCE │ ENDPOINT │
├──────────────────────────────────────┼────────────┼───────────────────────────────────────────────────┤
│ mcp_camera_and_recording_v1:builtin │ mcp_server │ 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. 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, resources/list, and tools/list include private caching hints with a one-hour TTL. The catalog does not change while the server is running. To change it, change the exposure document, refresh the repositories, and launch again.

Resources. A read returns the latest snapshot that passed the policies. Its ttlMs value says how long the snapshot remains fresh. The resource is unavailable until the first published value arrives. 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. Each accepted value notifies subscribers.

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.

Tasks. An action-backed tool requires a client that advertises the tasks capability. Without that capability, the endpoint rejects the call before creating a task. Otherwise, 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. The task ends as cancelled.
  4. On success, the task completes with the structured result. A goal that is rejected, abandoned, or expired 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.

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.
  • 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.
  • A contract sha256 does not match the resolved document’s bytes.

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

  • An exposure with no targets or a target that selects no members.
  • A public name claimed twice anywhere in the document, or a member selected twice by one target.
  • A blank title, instructions, or description value.
  • A zero deadline or a max_hz value that is not positive and finite.
  • A quality value on a raw representation.
  • on_oversize without max_result_bytes.
  • downscale without a jpeg 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.