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. peppy repo exposure checks the document against the exact pinned contract bytes and generates a complete MCP server node. 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. If a contract changes, the exposure must be republished with the new schemas or validation fails.
What an exposure publishes
Section titled “What an exposure publishes”An exposure draws from contracts. Each Peppy communication type has an MCP equivalent:
| Contract member | Published as | What a client can do |
|---|---|---|
| Topic | Resource | Read the latest policy-approved snapshot and subscribe to updates |
| Service | Tool | Call it and get the answer in the same request |
| Action | Tool backed by an MCP task | Get 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.
The exposure document
Section titled “The exposure document”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, and peppy repo index discovers it by its content.
Here is a complete example with two nodes. The camera provides an image resource and two tools. The recorder provides a record_episode tool that creates a task and requires confirmation.
// 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.//// Publish it with `peppy repo exposure exposures/camera_and_recording.json5`,// which validates every selection against the pinned contract bytes and// writes two artifacts beside this document: the bundle// (`camera_and_recording.bundle.json`) and the generated MCP server node// (`camera_and_recording_mcp/`).{ 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 on the generated 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", // The sha256 of the exact contract document this file was written // against (`sha256sum contracts/rgb_camera.json5`). Mandatory: the // published schemas are derived from these bytes, so `name:tag` // alone would not fix what gets published. 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: { contract: { name: "episode_recording", tag: "v1", sha256: "a4cfd6ec023d2359c3b9911bf829a565b6e6a6b3b83d57df146c4a87f177c0d2", },
// 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:
{ 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" }, }, ], },}{ 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.
Targets are logical, not instances
Section titled “Targets are logical, not instances”Each key under targets names a role, not a running node. In the generated server node, front_camera becomes the link_id of a depends_on.contracts slot. 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 republished.
A target must select at least one member, and an exposure must declare at least one target.
Contracts are pinned by content
Section titled “Contracts are pinned by content”The sha256 on a target’s contract reference is required. This differs from the optional author pins described in Content pins. A name:tag can point to changed content, and Peppy derives the public JSON Schemas from that content. The hash makes sure the exposure always uses the exact contract it was written for.
The required value is the SHA-256 hash of the contract document’s exact bytes. Run sha256sum contracts/rgb_camera.json5 to get it. If you do not know the hash, publish with any 64-character placeholder. The command will fail and show the hash of the resolved document.
target `front_camera` pins contract `rgb_camera:v1` at sha256 `0000…0000`,but the resolved document's bytes fingerprint to `44527ab0…fb736ab`Public names
Section titled “Public names”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>.
Policies
Section titled “Policies”Policies limit how each published member can be used. You declare them for each member, and the running server enforces them.
Topic policies
Section titled “Topic policies”| Field | Required | What it does |
|---|---|---|
freshness.max_age_ms | yes | Treat a snapshot older than this as stale instead of returning it as current |
update.max_hz | yes | Limit how often the snapshot refreshes and notifies subscribers. Faster messages are dropped before decoding or transcoding |
representation | no | Interpret the message as an image and publish it in a codec (see below) |
max_result_bytes | conditional | Cap on the serialized snapshot content, after representation runs |
on_oversize | conditional | What 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_bytesis optional. If you set it, it must be at least the maximum possible size. Do not seton_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 bothmax_result_bytesandon_oversizeto 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.
Service policies
Section titled “Service policies”| Field | Required | What it does |
|---|---|---|
operation | yes | read_only or mutating, advertised to the client. Long-running work is an action, so there is no third value |
deadline_ms | yes | How long the call waits before it comes back as a tool error |
restrict | no | Inclusive min/max bounds narrowing numeric request fields |
max_result_bytes | no | Cap 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, publication 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.
Action policies
Section titled “Action policies”| Field | Required | What it does |
|---|---|---|
operation | yes | long_running, the only value; actions are long-running by definition |
deadline_ms | yes | Whole-goal deadline. The advertised task TTL is this plus a short grace window |
safety_sensitive | no | Advertises that the call changes the physical world. Defaults to false |
confirmation_required | no | The task waits in input_required and sends no goal until the client confirms through tasks/update. Defaults to false |
The published schemas
Section titled “The published schemas”You do not define request and response shapes in the exposure. Peppy derives them from the contract’s message_format declarations. The bundle 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_format | Published JSON Schema | Why |
|---|---|---|
bool, string, f32, f64 | boolean, string, number | Direct |
u8…u32, i8…i32 | integer with minimum/maximum | The type’s own range is published |
u64, i64 | string with a canonical decimal pattern | JSON numbers lose precision above 253 |
time | string, format: "date-time" | RFC 3339, fractional seconds carrying full nanosecond precision |
bytes, array of u8 | string, contentEncoding: "base64" | Identical on the wire, so they share one rendering. A fixed byte length pins the exact base64 length |
| Other arrays | array with items; fixed length pins minItems/maxItems | Direct |
| Nested objects | object with every property required | $optional is legal only at the top level |
$optional top-level field | Omitted from required | Direct |
Public property names use the DSL’s snake_case spelling. The internal wire encoding uses lowerCamelCase instead. Every generated object schema sets additionalProperties: false.
Publish it
Section titled “Publish it”peppy repo exposure exposures/camera_and_recording.json5 # write the artifactspeppy repo exposure exposures/camera_and_recording.json5 --check # verify the committed onesPeppy resolves the pinned contracts through the local repository caches. Run peppy repo refresh on the machine first. Neither command needs a running daemon.
Publication writes two artifacts next to the document:
exposures/├── camera_and_recording.json5 # the document you wrote├── camera_and_recording.bundle.json # the derived public catalog└── camera_and_recording_mcp/ # the generated MCP server node ├── peppy.json5 ├── Cargo.toml ├── .gitignore └── src/ ├── main.rs ├── bridges.rs └── bundle.jsonThe bundle is the public catalog. It contains every resource, tool, and task with its stable name, description, policies, and generated JSON Schemas. It also contains the server node’s identity and contract slots.
The node is a complete generated Rust crate:
peppy.json5declares onedepends_on.contractsslot per logical target, consumes exactly the selected members, and takes a singleportargument (default8900).bridges.rstranslates in both directions between the public JSON and typed peppygen clients.main.rsuses the sharedpeppy-mcp-runtimecrate, feeds exposed topics into their resources, and binds the endpoint.src/bundle.jsonis an exact copy of the published bundle and is served as the catalog.
Commit both artifacts to the hub alongside the exposure document. The --check option regenerates them and fails if the committed files do not match byte for byte. It lists every file that differs. Run it in CI next to peppy repo index --check to prevent the catalog from drifting away from its exposure document. The check compares only generated files and ignores a Cargo.lock committed beside them. To fix drift, rerun the command and commit the generated files.
Deploy it
Section titled “Deploy it”The generated server is an ordinary node/v1, so peppy repo index discovers it like any other node. Its name is <exposure name>_mcp, and it uses the exposure’s tag. A launcher deploys it with one links entry per target. Each entry connects a logical role to a running instance.
// Deploying an MCP exposure: the providers by instance, and the generated// MCP server node filling its logical targets through `links`.//// peppy stack launch camera_and_recording_mcp{ 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 node `peppy repo exposure` generated. It is an ordinary // `node/v1` in the repository index, deployed by `name:tag` like any // other, and named `<exposure name>_mcp` at the exposure's tag. source: { name: "camera_and_recording_mcp:v1" }, instances: [ { instance_id: "mcp_server", // The only argument the generated node takes. It binds // `127.0.0.1:<port>` and serves MCP under `/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", }, }, ], }, ],}peppy stack launch camera_and_recording_mcpThe node binds to 127.0.0.1:<port> and serves MCP under /mcp. Every other path returns 404. Point a client at http://127.0.0.1:8900/mcp.
What a client sees
Section titled “What a client sees”Discovery. server/discover returns the exposure’s title and instructions, the implementation identity, 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, republish the exposure and redeploy the node.
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:
- If
confirmation_required, the task waits ininput_requireduntil the client confirms throughtasks/update. No goal is sent before that. - The server sends the goal. Action feedback updates the task’s status message.
tasks/cancelforwards a request to the action’s cooperative cancellation path. The task ends ascancelled.- On success, the task completes with the structured result. A goal that is rejected, abandoned, or expired ends as
failedwith a message saying which event occurred.
Task handles outlive connections. A client that reconnects can continue and observe the same task.
What validation refuses
Section titled “What validation refuses”Publication 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 public name is claimed twice anywhere in the document.
- A message definition cannot be converted to a public schema.
- A representation role names a missing,
$optional, or wrongly typed member. - A
restrictbound does not fit its member’s type, names a non-numeric member, or targets au64/i64. - A
max_result_bytesis smaller than a payload’s static maximum, or a topic with an unbounded payload omits the size policy. - A contract hash does not match the resolved document’s bytes.
Parsing catches basic document errors before these publication checks. Examples include:
- An exposure with no targets or a target that selects no members.
- A duplicated member, or a blank
title,instructions, ordescriptionvalue. - A zero deadline or a
max_hzvalue that is not positive and finite. - A
qualityvalue on arawrepresentation. on_oversizewithoutmax_result_bytes.downscalewithout ajpegrepresentation.
What stays private
Section titled “What stays private”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/listandresources/listreturn only what is in the bundle. The endpoint rejects unknown names. - The endpoint binds to
127.0.0.1. It is available from another machine only if you deliberately configure network access.