11//! Claude Code-specific MCP extensions.
22//!
3- //! Claude Code (claude-cli) implements two server-facing extensions that aren't in
4- //! the upstream MCP spec: a per-tool output-size override carried in `_meta`
5- //! (`anthropic/maxResultSizeChars`, raising the default 25k-token cap up to a
6- //! 500 000-char ceiling), and an experimental push-message capability
7- //! (`claude/channel`). This module exposes constants plus a [`Tool`] builder
8- //! extension so servers can opt into both without hand-rolling JSON keys.
3+ //! Claude Code (claude-cli) implements several server-facing extensions that
4+ //! aren't in the upstream MCP spec. This module exposes first-class Rust APIs
5+ //! for each of them so servers can opt in without hand-rolling JSON keys or
6+ //! notification wire formats.
7+ //!
8+ //! - `anthropic/maxResultSizeChars` - per-tool `_meta` entry raising Claude
9+ //! Code's default 25 000-token output cap up to a 500 000-char ceiling.
10+ //! - `claude/channel` - experimental capability Claude Code honours when
11+ //! started with `--channels`, allowing the server to push out-of-band
12+ //! events into the running session.
13+ //! - `claude/channel/permission` - companion capability (Claude Code v2.1.81+)
14+ //! that opts the channel into remote permission-relay for tool approvals.
15+ //! - `notifications/claude/channel` - the notification method a channel emits
16+ //! to push events; payload is `{ content, meta? }`.
17+ //! - `structured_with_text_fallback` - MCP 2025-11-25 spec ("a tool that
18+ //! returns structured content SHOULD also return the serialized JSON in a
19+ //! TextContent block") + workaround for Claude Code #41361 (blank UI when
20+ //! `structuredContent` fails `outputSchema.safeParse`). Pushes a
21+ //! human-readable summary `Content::text` alongside the `Json<T>` structured
22+ //! value.
23+ //! - `lint::warn_if_over_2kb` - pure `tracing::warn!` helper catching Claude
24+ //! Code's silent 2 KB truncation of tool descriptions and server
25+ //! `instructions`.
926//!
1027//! All members are passive: they emit what Claude Code expects and are
1128//! silently ignored by every other MCP client.
1229//!
13- //! See <https://docs.anthropic.com/en/docs/claude-code/mcp>.
30+ //! See <https://docs.anthropic.com/en/docs/claude-code/mcp> and
31+ //! <https://code.claude.com/docs/en/channels-reference>.
1432//!
1533//! Enable via the `anthropic-ext` cargo feature on `rmcp`.
1634
35+ use std:: collections:: BTreeMap ;
36+
37+ use serde:: { Deserialize , Serialize } ;
1738use serde_json:: Value ;
1839
19- use crate :: model:: { ExtensionCapabilities , JsonObject , Meta , Tool } ;
40+ use crate :: model:: { ExperimentalCapabilities , JsonObject , Meta , Tool } ;
41+ #[ cfg( feature = "server" ) ]
42+ use crate :: {
43+ RoleServer ,
44+ handler:: server:: { tool:: IntoCallToolResult , wrapper:: Json } ,
45+ model:: { CallToolResult , Content , CustomNotification , ServerNotification } ,
46+ service:: { Peer , ServiceError } ,
47+ } ;
48+
49+ // ---------------------------------------------------------------------------
50+ // Per-tool output-size override (`anthropic/maxResultSizeChars`)
51+ // ---------------------------------------------------------------------------
2052
2153/// `_meta` key Claude Code honours on a tool's `tools/list` entry to raise the
2254/// per-tool text-output size threshold past the default 25 000-token cap.
@@ -28,13 +60,6 @@ pub const MAX_RESULT_SIZE_CHARS_META_KEY: &str = "anthropic/maxResultSizeChars";
2860/// Tools requesting a larger threshold are clamped to this value.
2961pub const MAX_RESULT_SIZE_CHARS_CEILING : u32 = 500_000 ;
3062
31- /// Experimental capability key Claude Code looks for when started with
32- /// `--channels`, allowing the server to push messages into the session
33- /// as out-of-band notifications.
34- ///
35- /// See <https://docs.anthropic.com/en/docs/claude-code/mcp#push-messages-with-channels>.
36- pub const CLAUDE_CHANNEL_CAPABILITY : & str = "claude/channel" ;
37-
3863impl Tool {
3964 /// Claude Code-specific: set the `anthropic/maxResultSizeChars` `_meta`
4065 /// entry on this tool, raising the per-tool text-output threshold in
@@ -91,19 +116,176 @@ pub fn anthropic_max_result_size_chars_meta(chars: u32) -> Meta {
91116 meta
92117}
93118
94- /// Insert the `claude/channel` extension capability into an
95- /// [`ExtensionCapabilities`] map with empty settings. Pass the resulting map
96- /// to [`ServerCapabilitiesBuilder::enable_extensions_with`] when building
97- /// your server's [`ServerCapabilities`].
119+ // ---------------------------------------------------------------------------
120+ // Channels (`claude/channel` + `claude/channel/permission`)
121+ // ---------------------------------------------------------------------------
122+
123+ /// Experimental capability key Claude Code looks for when started with
124+ /// `--channels`, allowing the server to push messages into the session
125+ /// as out-of-band notifications.
126+ ///
127+ /// See <https://code.claude.com/docs/en/channels-reference>.
128+ pub const CLAUDE_CHANNEL_CAPABILITY : & str = "claude/channel" ;
129+
130+ /// Experimental capability key Claude Code looks for on channel servers that
131+ /// want tool-approval prompts relayed through the channel (Claude Code
132+ /// v2.1.81+). Declare alongside [`CLAUDE_CHANNEL_CAPABILITY`] to opt in.
133+ ///
134+ /// See <https://code.claude.com/docs/en/channels-reference#relay-permission-prompts>.
135+ pub const CLAUDE_CHANNEL_PERMISSION_CAPABILITY : & str = "claude/channel/permission" ;
136+
137+ /// JSON-RPC method name Claude Code listens on for channel events.
138+ ///
139+ /// See <https://code.claude.com/docs/en/channels-reference#notification-format>.
140+ pub const CLAUDE_CHANNEL_NOTIFICATION_METHOD : & str = "notifications/claude/channel" ;
141+
142+ /// Insert the `claude/channel` capability into an
143+ /// [`ExperimentalCapabilities`] map with empty settings. Pass the resulting
144+ /// map to
145+ /// [`ServerCapabilitiesBuilder::enable_experimental_with`](crate::model::ServerCapabilities)
146+ /// when building your server's [`ServerCapabilities`](crate::model::ServerCapabilities).
147+ ///
148+ /// Claude Code reads this key from `capabilities.experimental['claude/channel']`
149+ /// specifically - not the `extensions` field - per the
150+ /// [Claude Code channels reference](https://code.claude.com/docs/en/channels-reference#server-options).
151+ /// Silently ignored by every MCP client other than Claude Code.
152+ pub fn insert_claude_channel ( capabilities : & mut ExperimentalCapabilities ) {
153+ capabilities. insert ( CLAUDE_CHANNEL_CAPABILITY . to_string ( ) , JsonObject :: new ( ) ) ;
154+ }
155+
156+ /// Insert the `claude/channel/permission` capability into an
157+ /// [`ExperimentalCapabilities`] map with empty settings. Declare alongside
158+ /// [`insert_claude_channel`] when the channel wants to receive relayed
159+ /// tool-approval prompts. Requires Claude Code v2.1.81+.
98160///
99161/// Silently ignored by every MCP client other than Claude Code.
162+ /// See <https://code.claude.com/docs/en/channels-reference#relay-permission-prompts>.
163+ pub fn insert_claude_channel_permission ( capabilities : & mut ExperimentalCapabilities ) {
164+ capabilities. insert (
165+ CLAUDE_CHANNEL_PERMISSION_CAPABILITY . to_string ( ) ,
166+ JsonObject :: new ( ) ,
167+ ) ;
168+ }
169+
170+ /// Payload of a [`CLAUDE_CHANNEL_NOTIFICATION_METHOD`] notification.
171+ ///
172+ /// `content` becomes the body of the `<channel source="..." ...>` tag injected
173+ /// into Claude's context; each entry in `meta` becomes an attribute on that
174+ /// tag. Meta keys must be identifiers (letters, digits, underscore) - keys
175+ /// containing hyphens or other characters are silently dropped by Claude Code
176+ /// on the receiving side.
177+ ///
178+ /// See <https://code.claude.com/docs/en/channels-reference#notification-format>.
179+ #[ derive( Debug , Clone , Serialize , Deserialize ) ]
180+ #[ cfg_attr( feature = "schemars" , derive( schemars:: JsonSchema ) ) ]
181+ #[ expect( clippy:: exhaustive_structs, reason = "intentionally exhaustive" ) ]
182+ pub struct ClaudeChannelNotificationParam {
183+ /// Body of the `<channel>` tag.
184+ pub content : String ,
185+ /// Optional meta attributes. Keys must match `[A-Za-z0-9_]+`.
186+ #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
187+ pub meta : Option < BTreeMap < String , String > > ,
188+ }
189+
190+ impl ClaudeChannelNotificationParam {
191+ /// Build a new notification payload with only the required `content`.
192+ pub fn new ( content : impl Into < String > ) -> Self {
193+ Self {
194+ content : content. into ( ) ,
195+ meta : None ,
196+ }
197+ }
198+
199+ /// Attach a single meta attribute. The key must match `[A-Za-z0-9_]+` or
200+ /// Claude Code will silently drop it.
201+ #[ must_use]
202+ pub fn with_meta ( mut self , key : impl Into < String > , value : impl Into < String > ) -> Self {
203+ self . meta
204+ . get_or_insert_with ( BTreeMap :: new)
205+ . insert ( key. into ( ) , value. into ( ) ) ;
206+ self
207+ }
208+
209+ /// Replace the meta map wholesale.
210+ #[ must_use]
211+ pub fn with_meta_map ( mut self , meta : BTreeMap < String , String > ) -> Self {
212+ self . meta = Some ( meta) ;
213+ self
214+ }
215+ }
216+
217+ #[ cfg( feature = "server" ) ]
218+ impl Peer < RoleServer > {
219+ /// Push a `notifications/claude/channel` event into the running Claude
220+ /// Code session. No-op on every other MCP client (wire-level: the client
221+ /// sees an unknown notification and drops it).
222+ ///
223+ /// See <https://code.claude.com/docs/en/channels-reference#notification-format>.
224+ pub async fn notify_claude_channel (
225+ & self ,
226+ params : ClaudeChannelNotificationParam ,
227+ ) -> Result < ( ) , ServiceError > {
228+ // ClaudeChannelNotificationParam contains only `String` + `BTreeMap<String, String>`
229+ // fields; serde_json cannot fail for that shape, so an `.expect` is sound.
230+ let value = serde_json:: to_value ( & params)
231+ . expect ( "ClaudeChannelNotificationParam is always serializable" ) ;
232+ self . send_notification ( ServerNotification :: CustomNotification (
233+ CustomNotification :: new ( CLAUDE_CHANNEL_NOTIFICATION_METHOD , Some ( value) ) ,
234+ ) )
235+ . await
236+ }
237+ }
238+
239+ // ---------------------------------------------------------------------------
240+ // Structured-content + text-fallback helper
241+ // ---------------------------------------------------------------------------
242+
243+ /// Build a [`CallToolResult`] carrying both structured JSON (`structured_content`)
244+ /// and a **human-readable** text summary (`content`). Addresses two concerns at
245+ /// once:
246+ ///
247+ /// - **MCP 2025-11-25 spec:** `/server/tools` says "a tool that returns
248+ /// structured content SHOULD also return the serialized JSON in a
249+ /// TextContent block". [`Json<T>::into_call_tool_result`] already emits the
250+ /// serialized JSON as text. This helper appends a second text block that is
251+ /// human-readable rather than a raw JSON dump.
252+ /// - **Claude Code #41361:** if `structuredContent` fails the client-side
253+ /// `outputSchema.safeParse`, Claude Code renders the tool call **blank** in
254+ /// the UI even though the model still receives the structured value. The
255+ /// extra human-readable text block stays visible to both.
256+ ///
257+ /// `text_summary` receives a borrow of the value so callers can render fields
258+ /// without cloning.
259+ ///
260+ /// # Example
100261///
101- /// [`ServerCapabilitiesBuilder::enable_extensions_with`]: crate::model::ServerCapabilities
102- /// [`ServerCapabilities`]: crate::model::ServerCapabilities
103- pub fn insert_claude_channel ( extensions : & mut ExtensionCapabilities ) {
104- extensions. insert ( CLAUDE_CHANNEL_CAPABILITY . to_string ( ) , JsonObject :: new ( ) ) ;
262+ /// ```ignore
263+ /// use rmcp::anthropic_ext::structured_with_text_fallback;
264+ ///
265+ /// let resp = ListPlacesResponse { places, next_cursor, truncated: false };
266+ /// return structured_with_text_fallback(resp, |r| {
267+ /// format!("{} places{}", r.places.len(), if r.truncated { " (truncated)" } else { "" })
268+ /// })
269+ /// .map_err(|e| e.to_string());
270+ /// ```
271+ #[ cfg( feature = "server" ) ]
272+ pub fn structured_with_text_fallback < T > (
273+ value : T ,
274+ text_summary : impl FnOnce ( & T ) -> String ,
275+ ) -> Result < CallToolResult , crate :: ErrorData >
276+ where
277+ T : serde:: Serialize + schemars:: JsonSchema + ' static ,
278+ {
279+ let summary = text_summary ( & value) ;
280+ let mut result = Json ( value) . into_call_tool_result ( ) ?;
281+ result. content . push ( Content :: text ( summary) ) ;
282+ Ok ( result)
105283}
106284
285+ // ---------------------------------------------------------------------------
286+ // Lint helpers
287+ // ---------------------------------------------------------------------------
288+
107289/// Lint helpers for catching common Claude Code integration pitfalls at
108290/// runtime. All helpers are pure - they `tracing::warn!` when a condition is
109291/// violated but never change behavior.
@@ -171,13 +353,76 @@ mod tests {
171353 }
172354
173355 #[ test]
174- fn claude_channel_extension_inserted_empty ( ) {
175- let mut ext = ExtensionCapabilities :: new ( ) ;
176- insert_claude_channel ( & mut ext) ;
177- let stored = ext. get ( CLAUDE_CHANNEL_CAPABILITY ) . expect ( "capability set" ) ;
356+ fn claude_channel_capability_inserted_empty ( ) {
357+ let mut experimental = ExperimentalCapabilities :: new ( ) ;
358+ insert_claude_channel ( & mut experimental) ;
359+ let stored = experimental
360+ . get ( CLAUDE_CHANNEL_CAPABILITY )
361+ . expect ( "capability set" ) ;
178362 assert ! ( stored. is_empty( ) , "expected empty settings object" ) ;
179363 }
180364
365+ #[ test]
366+ fn claude_channel_capability_lands_on_experimental_builder_field ( ) {
367+ use crate :: model:: ServerCapabilities ;
368+
369+ let mut experimental = ExperimentalCapabilities :: new ( ) ;
370+ insert_claude_channel ( & mut experimental) ;
371+ let caps = ServerCapabilities :: builder ( )
372+ . enable_experimental_with ( experimental)
373+ . build ( ) ;
374+
375+ let experimental = caps. experimental . expect ( "experimental populated" ) ;
376+ assert ! (
377+ experimental. contains_key( CLAUDE_CHANNEL_CAPABILITY ) ,
378+ "channel key must land on `capabilities.experimental`, not `.extensions`"
379+ ) ;
380+ assert ! (
381+ caps. extensions. is_none( ) ,
382+ "extensions field must stay empty when only `experimental` was populated"
383+ ) ;
384+ }
385+
386+ #[ test]
387+ fn claude_channel_permission_coexists_with_channel ( ) {
388+ let mut experimental = ExperimentalCapabilities :: new ( ) ;
389+ insert_claude_channel ( & mut experimental) ;
390+ insert_claude_channel_permission ( & mut experimental) ;
391+ assert ! ( experimental. contains_key( CLAUDE_CHANNEL_CAPABILITY ) ) ;
392+ assert ! ( experimental. contains_key( CLAUDE_CHANNEL_PERMISSION_CAPABILITY ) ) ;
393+ assert_eq ! ( experimental. len( ) , 2 ) ;
394+ }
395+
396+ #[ test]
397+ fn channel_notification_param_round_trips ( ) {
398+ let param = ClaudeChannelNotificationParam :: new ( "hello" )
399+ . with_meta ( "chat_id" , "42" )
400+ . with_meta ( "severity" , "high" ) ;
401+
402+ let json = serde_json:: to_value ( & param) . expect ( "serialize" ) ;
403+ assert_eq ! ( json[ "content" ] , "hello" ) ;
404+ assert_eq ! ( json[ "meta" ] [ "chat_id" ] , "42" ) ;
405+ assert_eq ! ( json[ "meta" ] [ "severity" ] , "high" ) ;
406+
407+ let back: ClaudeChannelNotificationParam =
408+ serde_json:: from_value ( json) . expect ( "deserialize" ) ;
409+ assert_eq ! ( back. content, "hello" ) ;
410+ let meta = back. meta . expect ( "meta preserved" ) ;
411+ assert_eq ! ( meta. get( "chat_id" ) . map( String :: as_str) , Some ( "42" ) ) ;
412+ assert_eq ! ( meta. get( "severity" ) . map( String :: as_str) , Some ( "high" ) ) ;
413+ }
414+
415+ #[ test]
416+ fn channel_notification_param_omits_meta_when_empty ( ) {
417+ let param = ClaudeChannelNotificationParam :: new ( "ping" ) ;
418+ let json = serde_json:: to_value ( & param) . expect ( "serialize" ) ;
419+ assert_eq ! ( json[ "content" ] , "ping" ) ;
420+ assert ! (
421+ json. get( "meta" ) . is_none( ) ,
422+ "meta key should be omitted when None via skip_serializing_if"
423+ ) ;
424+ }
425+
181426 #[ test]
182427 fn anthropic_max_result_size_chars_meta_under_ceiling ( ) {
183428 let meta = anthropic_max_result_size_chars_meta ( 100_000 ) ;
@@ -217,4 +462,33 @@ mod tests {
217462 let body = "x" . repeat ( lint:: SIZE_LIMIT_BYTES ) ;
218463 assert ! ( !lint:: warn_if_over_2kb( "test" , & body) ) ;
219464 }
465+
466+ #[ cfg( feature = "server" ) ]
467+ #[ test]
468+ fn structured_with_text_fallback_appends_summary ( ) {
469+ #[ derive( serde:: Serialize , schemars:: JsonSchema ) ]
470+ struct Resp {
471+ count : u32 ,
472+ }
473+
474+ let result =
475+ structured_with_text_fallback ( Resp { count : 3 } , |r| format ! ( "{} items" , r. count) )
476+ . expect ( "structured result" ) ;
477+
478+ assert ! (
479+ result. structured_content. is_some( ) ,
480+ "structured content set"
481+ ) ;
482+ // Json<T>::into_call_tool_result pushes one text entry (the serialized
483+ // JSON); we append a second human-readable summary.
484+ assert_eq ! ( result. content. len( ) , 2 , "serialized JSON + summary" ) ;
485+
486+ let last_text = result
487+ . content
488+ . last ( )
489+ . expect ( "content non-empty" )
490+ . as_text ( )
491+ . expect ( "final entry is text" ) ;
492+ assert_eq ! ( last_text. text, "3 items" ) ;
493+ }
220494}
0 commit comments