Skip to content

Commit 523c9a6

Browse files
committed
feat(anthropic-ext): add channel permission-relay typed API
Extends the `anthropic-ext` feature so autonomous pipelines can auto-approve Claude Code tool-permission dialogs without hand-rolling the notification wire format. Builds on the existing `insert_claude_channel_permission` capability registration and `notify_claude_channel` outbound event path. Additions in `crates/rmcp/src/anthropic_ext.rs`: - `CLAUDE_CHANNEL_PERMISSION_REQUEST_METHOD` / `CLAUDE_CHANNEL_PERMISSION_METHOD` — method-name constants. - `ClaudeChannelPermissionRequestParam` — deserializable payload of the inbound `notifications/claude/channel/permission_request` (`{request_id, tool_name, description, input_preview}`). Consumers parse via `CustomNotification::params_as::<T>()` inside their `ServerHandler::on_custom_notification` override. - `ClaudeChannelPermissionVerdict::{Allow, Deny}` — snake-case serde enum for the verdict. - `ClaudeChannelPermissionParam::{new, allow, deny}` — verdict payload with terse constructors for the common auto-approve path. - `Peer<RoleServer>::notify_claude_channel_permission(params)` — outbound emitter mirroring `notify_claude_channel`, with an `#[cfg]`-gated full runnable example in the doc comment. No core rmcp dispatch changes: rmcp already ships `ClientNotification::CustomNotification` pass-through + `ServerHandler::on_custom_notification` with a no-op default, so the module stays fully self-contained (no upstream-merge surface area added). Tests (7 new in the existing `#[cfg(test)] mod tests` block, 20/20 passing): method-name constants, deserialization from the exact payload Claude Code sends, verdict snake-case + round-trip, outbound wire-shape, `::allow` / `::deny` constructors, and end-to-end parse via `CustomNotification::params_as`. Ref: https://code.claude.com/docs/en/channels-reference#relay-permission-prompts
1 parent 294d739 commit 523c9a6

1 file changed

Lines changed: 281 additions & 0 deletions

File tree

crates/rmcp/src/anthropic_ext.rs

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@
1414
//! that opts the channel into remote permission-relay for tool approvals.
1515
//! - `notifications/claude/channel` - the notification method a channel emits
1616
//! to push events; payload is `{ content, meta? }`.
17+
//! - `notifications/claude/channel/permission_request` - inbound notification
18+
//! Claude Code sends when a tool-approval dialog opens on a channel that
19+
//! declared `claude/channel/permission`; payload is `{ request_id,
20+
//! tool_name, description, input_preview }`. Servers parse via
21+
//! [`CustomNotification::params_as`](crate::model::CustomNotification::params_as)
22+
//! inside their `ServerHandler::on_custom_notification` override.
23+
//! - `notifications/claude/channel/permission` - outbound verdict the server
24+
//! emits back; payload is `{ request_id, behavior: "allow" | "deny" }`.
25+
//! Emit via [`Peer::notify_claude_channel_permission`].
1726
//! - `structured_with_text_fallback` - MCP 2025-11-25 spec ("a tool that
1827
//! returns structured content SHOULD also return the serialized JSON in a
1928
//! TextContent block") + workaround for Claude Code #41361 (blank UI when
@@ -236,6 +245,175 @@ impl Peer<RoleServer> {
236245
}
237246
}
238247

248+
// ---------------------------------------------------------------------------
249+
// Channel permission relay (`claude/channel/permission`)
250+
// ---------------------------------------------------------------------------
251+
252+
/// JSON-RPC method Claude Code uses to forward a tool-approval prompt into a
253+
/// channel that declared [`CLAUDE_CHANNEL_PERMISSION_CAPABILITY`]. The server
254+
/// receives this as an inbound [`CustomNotification`]; parse via
255+
/// [`CustomNotification::params_as`](crate::model::CustomNotification::params_as)
256+
/// into [`ClaudeChannelPermissionRequestParam`].
257+
///
258+
/// See <https://code.claude.com/docs/en/channels-reference#relay-permission-prompts>.
259+
pub const CLAUDE_CHANNEL_PERMISSION_REQUEST_METHOD: &str =
260+
"notifications/claude/channel/permission_request";
261+
262+
/// JSON-RPC method the server emits back to Claude Code carrying the verdict
263+
/// for a prior [`CLAUDE_CHANNEL_PERMISSION_REQUEST_METHOD`]. Emit typed via
264+
/// [`Peer::notify_claude_channel_permission`].
265+
///
266+
/// See <https://code.claude.com/docs/en/channels-reference#relay-permission-prompts>.
267+
pub const CLAUDE_CHANNEL_PERMISSION_METHOD: &str = "notifications/claude/channel/permission";
268+
269+
/// Payload of an inbound [`CLAUDE_CHANNEL_PERMISSION_REQUEST_METHOD`]
270+
/// notification. Claude Code sends one of these whenever a local tool-approval
271+
/// dialog opens on a channel that declared
272+
/// [`CLAUDE_CHANNEL_PERMISSION_CAPABILITY`].
273+
///
274+
/// `request_id` is five lowercase letters drawn from `a`-`z` skipping `l`; it
275+
/// must be echoed verbatim in the corresponding
276+
/// [`ClaudeChannelPermissionParam::request_id`] or Claude Code drops the
277+
/// verdict silently. The local terminal dialog never displays this ID.
278+
///
279+
/// See <https://code.claude.com/docs/en/channels-reference#permission-request-fields>.
280+
#[derive(Debug, Clone, Serialize, Deserialize)]
281+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
282+
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
283+
pub struct ClaudeChannelPermissionRequestParam {
284+
/// Five-letter ID Claude Code issued for this dialog; echo verbatim.
285+
pub request_id: String,
286+
/// Tool Claude wants to invoke (e.g. `"Bash"`, `"Write"`).
287+
pub tool_name: String,
288+
/// Human-readable summary of the specific call; same text the local
289+
/// terminal dialog shows.
290+
pub description: String,
291+
/// Tool arguments rendered as a JSON string, truncated by Claude Code to
292+
/// ~200 characters.
293+
pub input_preview: String,
294+
}
295+
296+
/// Verdict the server returns in a [`CLAUDE_CHANNEL_PERMISSION_METHOD`]
297+
/// notification. `Allow` proceeds with the tool call; `Deny` rejects it
298+
/// (equivalent to answering "No" in the local dialog). Neither verdict
299+
/// affects future calls — each dialog is independent.
300+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
301+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
302+
#[serde(rename_all = "snake_case")]
303+
#[expect(clippy::exhaustive_enums, reason = "verdict set is spec-defined")]
304+
pub enum ClaudeChannelPermissionVerdict {
305+
/// Proceed with the tool call.
306+
Allow,
307+
/// Reject the tool call.
308+
Deny,
309+
}
310+
311+
/// Payload of an outbound [`CLAUDE_CHANNEL_PERMISSION_METHOD`] notification.
312+
/// Pair `request_id` with the value received in the corresponding inbound
313+
/// [`ClaudeChannelPermissionRequestParam::request_id`] exactly — Claude Code
314+
/// only accepts a verdict that carries an ID it issued and is still pending.
315+
///
316+
/// See <https://code.claude.com/docs/en/channels-reference#relay-permission-prompts>.
317+
#[derive(Debug, Clone, Serialize, Deserialize)]
318+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
319+
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
320+
pub struct ClaudeChannelPermissionParam {
321+
/// Five-letter ID echoed from the originating
322+
/// [`ClaudeChannelPermissionRequestParam::request_id`].
323+
pub request_id: String,
324+
/// Verdict to apply: `Allow` or `Deny`.
325+
pub behavior: ClaudeChannelPermissionVerdict,
326+
}
327+
328+
impl ClaudeChannelPermissionParam {
329+
/// Build a verdict payload from an ID and behavior.
330+
pub fn new(request_id: impl Into<String>, behavior: ClaudeChannelPermissionVerdict) -> Self {
331+
Self {
332+
request_id: request_id.into(),
333+
behavior,
334+
}
335+
}
336+
337+
/// Shorthand for `new(id, Allow)` — the canonical autonomous-pipeline
338+
/// auto-approve path.
339+
#[must_use]
340+
pub fn allow(request_id: impl Into<String>) -> Self {
341+
Self::new(request_id, ClaudeChannelPermissionVerdict::Allow)
342+
}
343+
344+
/// Shorthand for `new(id, Deny)`.
345+
#[must_use]
346+
pub fn deny(request_id: impl Into<String>) -> Self {
347+
Self::new(request_id, ClaudeChannelPermissionVerdict::Deny)
348+
}
349+
}
350+
351+
#[cfg(feature = "server")]
352+
impl Peer<RoleServer> {
353+
/// Push a `notifications/claude/channel/permission` verdict back to Claude
354+
/// Code. Call this from
355+
/// [`ServerHandler::on_custom_notification`](crate::ServerHandler::on_custom_notification)
356+
/// once a [`CLAUDE_CHANNEL_PERMISSION_REQUEST_METHOD`] notification has
357+
/// been parsed into a [`ClaudeChannelPermissionRequestParam`] and a
358+
/// verdict has been decided. `request_id` in `params` must match the
359+
/// originating request or Claude Code drops the verdict silently.
360+
///
361+
/// Autonomous pipelines typically pair this with a sender-gated channel
362+
/// and always emit `Allow` (no human in the loop to make the call). See
363+
/// <https://code.claude.com/docs/en/channels-reference#relay-permission-prompts>.
364+
///
365+
/// # Example
366+
///
367+
/// ```ignore
368+
/// use rmcp::{
369+
/// ServerHandler,
370+
/// anthropic_ext::{
371+
/// CLAUDE_CHANNEL_PERMISSION_REQUEST_METHOD,
372+
/// ClaudeChannelPermissionParam,
373+
/// ClaudeChannelPermissionRequestParam,
374+
/// },
375+
/// model::CustomNotification,
376+
/// service::{NotificationContext, RoleServer},
377+
/// };
378+
///
379+
/// impl ServerHandler for MyServer {
380+
/// async fn on_custom_notification(
381+
/// &self,
382+
/// notification: CustomNotification,
383+
/// context: NotificationContext<RoleServer>,
384+
/// ) {
385+
/// if notification.method != CLAUDE_CHANNEL_PERMISSION_REQUEST_METHOD {
386+
/// return;
387+
/// }
388+
/// let Ok(Some(req)) =
389+
/// notification.params_as::<ClaudeChannelPermissionRequestParam>()
390+
/// else {
391+
/// return;
392+
/// };
393+
/// let _ = context
394+
/// .peer
395+
/// .notify_claude_channel_permission(
396+
/// ClaudeChannelPermissionParam::allow(req.request_id),
397+
/// )
398+
/// .await;
399+
/// }
400+
/// }
401+
/// ```
402+
pub async fn notify_claude_channel_permission(
403+
&self,
404+
params: ClaudeChannelPermissionParam,
405+
) -> Result<(), ServiceError> {
406+
// ClaudeChannelPermissionParam contains only a `String` + a unit-like
407+
// enum, so `serde_json::to_value` cannot fail for that shape.
408+
let value = serde_json::to_value(&params)
409+
.expect("ClaudeChannelPermissionParam is always serializable");
410+
self.send_notification(ServerNotification::CustomNotification(
411+
CustomNotification::new(CLAUDE_CHANNEL_PERMISSION_METHOD, Some(value)),
412+
))
413+
.await
414+
}
415+
}
416+
239417
// ---------------------------------------------------------------------------
240418
// Structured-content + text-fallback helper
241419
// ---------------------------------------------------------------------------
@@ -491,4 +669,107 @@ mod tests {
491669
.expect("final entry is text");
492670
assert_eq!(last_text.text, "3 items");
493671
}
672+
673+
// ----- permission relay -------------------------------------------------
674+
675+
#[test]
676+
fn permission_method_constants_match_spec() {
677+
assert_eq!(
678+
CLAUDE_CHANNEL_PERMISSION_REQUEST_METHOD,
679+
"notifications/claude/channel/permission_request"
680+
);
681+
assert_eq!(
682+
CLAUDE_CHANNEL_PERMISSION_METHOD,
683+
"notifications/claude/channel/permission"
684+
);
685+
}
686+
687+
#[test]
688+
fn permission_request_param_deserializes_from_claude_code_payload() {
689+
let raw = serde_json::json!({
690+
"request_id": "abcde",
691+
"tool_name": "Bash",
692+
"description": "list the files in this directory",
693+
"input_preview": "{\"command\": \"ls\"}"
694+
});
695+
let parsed: ClaudeChannelPermissionRequestParam =
696+
serde_json::from_value(raw).expect("deserialize");
697+
assert_eq!(parsed.request_id, "abcde");
698+
assert_eq!(parsed.tool_name, "Bash");
699+
assert_eq!(parsed.description, "list the files in this directory");
700+
assert_eq!(parsed.input_preview, "{\"command\": \"ls\"}");
701+
}
702+
703+
#[test]
704+
fn permission_verdict_serializes_snake_case() {
705+
let allow = serde_json::to_value(ClaudeChannelPermissionVerdict::Allow).expect("serialize");
706+
let deny = serde_json::to_value(ClaudeChannelPermissionVerdict::Deny).expect("serialize");
707+
assert_eq!(allow, serde_json::Value::String("allow".into()));
708+
assert_eq!(deny, serde_json::Value::String("deny".into()));
709+
}
710+
711+
#[test]
712+
fn permission_verdict_round_trips() {
713+
for v in [
714+
ClaudeChannelPermissionVerdict::Allow,
715+
ClaudeChannelPermissionVerdict::Deny,
716+
] {
717+
let json = serde_json::to_value(v).expect("serialize");
718+
let back: ClaudeChannelPermissionVerdict =
719+
serde_json::from_value(json).expect("deserialize");
720+
assert_eq!(back, v);
721+
}
722+
}
723+
724+
#[test]
725+
fn permission_param_wire_shape_matches_spec() {
726+
let param = ClaudeChannelPermissionParam::allow("abcde");
727+
let json = serde_json::to_value(&param).expect("serialize");
728+
assert_eq!(json["request_id"], "abcde");
729+
assert_eq!(json["behavior"], "allow");
730+
assert_eq!(
731+
json.as_object().map(serde_json::Map::len),
732+
Some(2),
733+
"payload carries exactly `request_id` and `behavior`"
734+
);
735+
}
736+
737+
#[test]
738+
fn permission_param_allow_and_deny_constructors() {
739+
let allow = ClaudeChannelPermissionParam::allow("fghij");
740+
assert_eq!(allow.request_id, "fghij");
741+
assert_eq!(allow.behavior, ClaudeChannelPermissionVerdict::Allow);
742+
743+
let deny = ClaudeChannelPermissionParam::deny("klmno");
744+
assert_eq!(deny.request_id, "klmno");
745+
assert_eq!(deny.behavior, ClaudeChannelPermissionVerdict::Deny);
746+
}
747+
748+
#[test]
749+
fn permission_request_parseable_via_custom_notification_params_as() {
750+
// Mirrors the consumer path: inbound CustomNotification -> params_as::<T>().
751+
use crate::model::CustomNotification;
752+
753+
let payload = serde_json::json!({
754+
"request_id": "pqrst",
755+
"tool_name": "Write",
756+
"description": "Create new file README.md",
757+
"input_preview": "{\"path\":\"./README.md\",\"content\":\"# Proj...\"}"
758+
});
759+
let notification = CustomNotification::new(
760+
CLAUDE_CHANNEL_PERMISSION_REQUEST_METHOD,
761+
Some(payload.clone()),
762+
);
763+
assert_eq!(
764+
notification.method,
765+
CLAUDE_CHANNEL_PERMISSION_REQUEST_METHOD
766+
);
767+
768+
let parsed: ClaudeChannelPermissionRequestParam = notification
769+
.params_as()
770+
.expect("params_as succeeds")
771+
.expect("params were Some");
772+
assert_eq!(parsed.request_id, "pqrst");
773+
assert_eq!(parsed.tool_name, "Write");
774+
}
494775
}

0 commit comments

Comments
 (0)