|
14 | 14 | //! that opts the channel into remote permission-relay for tool approvals. |
15 | 15 | //! - `notifications/claude/channel` - the notification method a channel emits |
16 | 16 | //! 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`]. |
17 | 26 | //! - `structured_with_text_fallback` - MCP 2025-11-25 spec ("a tool that |
18 | 27 | //! returns structured content SHOULD also return the serialized JSON in a |
19 | 28 | //! TextContent block") + workaround for Claude Code #41361 (blank UI when |
@@ -236,6 +245,175 @@ impl Peer<RoleServer> { |
236 | 245 | } |
237 | 246 | } |
238 | 247 |
|
| 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(¶ms) |
| 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 | + |
239 | 417 | // --------------------------------------------------------------------------- |
240 | 418 | // Structured-content + text-fallback helper |
241 | 419 | // --------------------------------------------------------------------------- |
@@ -491,4 +669,107 @@ mod tests { |
491 | 669 | .expect("final entry is text"); |
492 | 670 | assert_eq!(last_text.text, "3 items"); |
493 | 671 | } |
| 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(¶m).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 | + } |
494 | 775 | } |
0 commit comments