Skip to content

Commit 294d739

Browse files
committed
feat(fork): bleeding-edge Claude CLI extensions + sep-2243 scaffold
anthropic-ext fixes and additions: - Fix `insert_claude_channel` capability-field bug: now takes `&mut ExperimentalCapabilities` so `claude/channel` lands on `capabilities.experimental.claude/channel` per the Claude Code channels reference (was emitted on `capabilities.extensions` and silently failed to register). - Add `insert_claude_channel_permission` for the v2.1.81+ `claude/channel/permission` capability (remote tool-approval relay). - Add `ClaudeChannelNotificationParam` + `Peer::notify_claude_channel` typed emitter for `notifications/claude/channel` (content + meta). - Add `structured_with_text_fallback` helper for the MCP 2025-11-25 "structured + text" recommendation and the Claude Code #41361 blank-UI workaround. sep-2243-draft scaffold (new feature, off by default): - Header name constants (`Mcp-Method`, `Mcp-Name`, `x-mcp-header`). - `Tool::with_mcp_header_param` builder marker stored in tool `_meta` under `io.modelcontextprotocol/x-mcp-header-param`. - `McpHttpHeaders::from_header_map` server-side header extractor. - Full transport plumbing lands when SEP-2243 finalizes. All new code is behind existing or new feature flags so upstream merges stay conflict-free. 19 new tests; lib clippy clean with -D warnings.
1 parent 846f013 commit 294d739

4 files changed

Lines changed: 553 additions & 27 deletions

File tree

crates/rmcp/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ exhaustive_enums = "warn"
1616
[package.metadata.docs.rs]
1717
features = [
1818
"anthropic-ext",
19+
"sep-2243-draft",
1920
"auth",
2021
"auth-client-credentials-jwt",
2122
"base64",
@@ -125,6 +126,12 @@ elicitation = ["dep:url"]
125126
# the fleet targets Claude Code as the MCP client.
126127
anthropic-ext = []
127128

129+
# Forward-looking scaffold for SEP-2243 (HTTP Header Standardization, Draft).
130+
# Exposes header-name constants, `Tool::with_mcp_header_param` builder, and
131+
# server-side header extraction. Full transport-layer plumbing lands when
132+
# SEP-2243 finalizes. Off by default.
133+
sep-2243-draft = []
134+
128135
# reqwest http client
129136
__reqwest = ["dep:reqwest"]
130137

crates/rmcp/src/anthropic_ext.rs

Lines changed: 301 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,54 @@
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};
1738
use 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.
2961
pub 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-
3863
impl 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

Comments
 (0)