Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- MCP protocol negotiation: every surface advertised the original 2024-11-05
revision and the stdio client required an exact match, so newer servers
could not connect. The server and both clients now advertise 2025-06-18
and negotiate over the supported set (2025-06-18, 2025-03-26, 2024-11-05)
— the server echoes the client's revision when it is supported and answers
with the latest otherwise, the stdio client accepts any supported revision,
and streamable HTTP sends the required `MCP-Protocol-Version` header on
every post-initialize request (#6280, first half).
- Configured MCP servers now connect lazily instead of all at session boot. The
pool owns a `connecting` set marked at spawn and cleared on resolution or
abort, so "connecting" is no longer inferred as enabled-minus-connected. The
Expand Down Expand Up @@ -114,6 +122,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `<recommended_plugins>` suggestions stop nagging: a plugin id is now
injected at most once per engine lifetime, and a plugin whose name a
loaded skill already covers is never suggested — the local skill owns
the domain, so the nudge was noise. Dismissals still apply, and the
fragment stays append-only on the user turn (#6274).
- A canceled automation run now settles with a transcript receipt that names
the cancellation (by request, cancel timeout, or shutdown) instead of
vanishing from the live band silently. The receipt wears attention ink and
Expand Down
27 changes: 21 additions & 6 deletions crates/mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1036,14 +1036,20 @@ fn default_rpc_methods() -> Vec<&'static str> {
]
}

const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
/// Latest dated MCP protocol revision this server implements. Codewhale's
/// MCP clients advertise the same revision at `initialize`.
pub(crate) const MCP_PROTOCOL_VERSION: &str = "2025-06-18";
/// Dated MCP revisions accepted during protocol negotiation, newest first.
/// Servers answering an older supported revision get it echoed back.
pub(crate) const MCP_SUPPORTED_PROTOCOL_VERSIONS: &[&str] =
&[MCP_PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
const MCP_SERVER_NAME: &str = "codewhale-mcp-server";

fn initialize_response(state: &StdioMcpState) -> Value {
fn initialize_response(state: &StdioMcpState, protocol_version: &str) -> Value {
json!({
// Standard MCP initialize result. Keep the management metadata below
// as additive compatibility fields for existing Codewhale clients.
"protocolVersion": MCP_PROTOCOL_VERSION,
"protocolVersion": protocol_version,
"capabilities": {
"tools": {},
"resources": {}
Expand Down Expand Up @@ -1344,12 +1350,21 @@ fn dispatch_stdio_request(
// Deserializing into a Map above is the object-shape check. The
// proxy does not currently consume any client capability.
let _client_capabilities = parsed.capabilities;
// Per spec, echo the requested revision when we support it;
// otherwise answer with the newest revision we do support and
// let the client decide whether to continue.
let negotiated =
if MCP_SUPPORTED_PROTOCOL_VERSIONS.contains(&parsed.protocol_version.as_str()) {
parsed.protocol_version
} else {
MCP_PROTOCOL_VERSION.to_string()
};
state.session_phase = McpSessionPhase::InitializeResponded;
Ok((initialize_response(state), false))
Ok((initialize_response(state, &negotiated), false))
}
// Pre-standard Codewhale management alias; it intentionally requires
// no MCP initialize envelope.
"capabilities" => Ok((initialize_response(state), false)),
"capabilities" => Ok((initialize_response(state, MCP_PROTOCOL_VERSION), false)),
"notifications/initialized" => {
if state.session_phase != McpSessionPhase::InitializeResponded {
return Err(JsonRpcError::invalid_request(
Expand Down Expand Up @@ -2434,7 +2449,7 @@ mod tests {
#[test]
fn stdio_initialize_uses_standard_mcp_shape_and_codewhale_identity() {
let state = build_stdio_state(Vec::new());
let response = initialize_response(&state);
let response = initialize_response(&state, MCP_PROTOCOL_VERSION);
assert_eq!(response["protocolVersion"], MCP_PROTOCOL_VERSION);
assert_eq!(response["serverInfo"]["name"], MCP_SERVER_NAME);
assert_eq!(response["serverInfo"]["version"], env!("CARGO_PKG_VERSION"));
Expand Down
39 changes: 27 additions & 12 deletions crates/mcp/src/stdio_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,10 @@ use std::os::windows::process::CommandExt;
use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Value, json};

use crate::{McpManagedClient, McpResourceDescriptor, McpServerConfig, McpToolDescriptor};

/// Protocol revision advertised during the handshake. Matches the revision the
/// TUI's MCP pool negotiates (`crates/tui/src/mcp.rs`), so a server that works
/// in the TUI works here.
const PROTOCOL_VERSION: &str = "2024-11-05";
use crate::{
MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, McpManagedClient, McpResourceDescriptor,
McpServerConfig, McpToolDescriptor,
};

/// Budget for spawn + `initialize` + `notifications/initialized`. Generous
/// because a first `npx`/`uvx` launch may download the server package.
Expand Down Expand Up @@ -436,9 +434,12 @@ fn validate_initialize_result(
.with_context(|| {
format!("MCP server '{server_name}': initialize result omitted protocolVersion")
})?;
if protocol_version != PROTOCOL_VERSION {
// Negotiation per spec: we advertise the newest revision and accept any
// dated revision we still implement; anything else ends the handshake.
if !MCP_SUPPORTED_PROTOCOL_VERSIONS.contains(&protocol_version) {
bail!(
"MCP server '{server_name}': unsupported protocol version '{protocol_version}' (expected {PROTOCOL_VERSION})"
"MCP server '{server_name}': unsupported protocol version '{protocol_version}' (supported: {})",
MCP_SUPPORTED_PROTOCOL_VERSIONS.join(", ")
);
}

Expand Down Expand Up @@ -626,7 +627,7 @@ impl ChildProcessMcpClient {
&server_name,
"initialize",
json!({
"protocolVersion": PROTOCOL_VERSION,
"protocolVersion": MCP_PROTOCOL_VERSION,
"clientInfo": {
"name": "codewhale-mcp-server",
"version": env!("CARGO_PKG_VERSION")
Expand Down Expand Up @@ -1192,7 +1193,7 @@ mod tests {
#[test]
fn initialize_result_requires_supported_protocol_and_server_identity() {
let valid = json!({
"protocolVersion": PROTOCOL_VERSION,
"protocolVersion": MCP_PROTOCOL_VERSION,
"serverInfo": {"name": "fixture", "version": "1"},
"capabilities": {"tools": {}, "resources": {}}
});
Expand All @@ -1202,18 +1203,32 @@ mod tests {
assert!(capabilities.tools);
assert!(capabilities.resources);

// Negotiation accepts every dated revision still implemented, not only
// the newest one advertised at initialize.
for version in ["2025-03-26", "2024-11-05"] {
let older = json!({
"protocolVersion": version,
"serverInfo": {"name": "fixture", "version": "1"},
"capabilities": {"tools": {}}
});
assert!(
validate_initialize_result("fixture", &older).is_ok(),
"supported revision {version} was rejected"
);
}

for invalid in [
json!({}),
json!({
"protocolVersion": "2099-01-01",
"serverInfo": {"name": "fixture", "version": "1"}
}),
json!({
"protocolVersion": PROTOCOL_VERSION,
"protocolVersion": MCP_PROTOCOL_VERSION,
"serverInfo": {"name": "fixture"}
}),
json!({
"protocolVersion": PROTOCOL_VERSION,
"protocolVersion": MCP_PROTOCOL_VERSION,
"serverInfo": {"name": "fixture", "version": "1"},
"capabilities": []
}),
Expand Down
13 changes: 13 additions & 0 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- MCP protocol negotiation: every surface advertised the original 2024-11-05
revision and the stdio client required an exact match, so newer servers
could not connect. The server and both clients now advertise 2025-06-18
and negotiate over the supported set (2025-06-18, 2025-03-26, 2024-11-05)
— the server echoes the client's revision when it is supported and answers
with the latest otherwise, the stdio client accepts any supported revision,
and streamable HTTP sends the required `MCP-Protocol-Version` header on
every post-initialize request (#6280, first half).
- Configured MCP servers now connect lazily instead of all at session boot. The
pool owns a `connecting` set marked at spawn and cleared on resolution or
abort, so "connecting" is no longer inferred as enabled-minus-connected. The
Expand Down Expand Up @@ -114,6 +122,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `<recommended_plugins>` suggestions stop nagging: a plugin id is now
injected at most once per engine lifetime, and a plugin whose name a
loaded skill already covers is never suggested — the local skill owns
the domain, so the nudge was noise. Dismissals still apply, and the
fragment stays append-only on the user turn (#6274).
- A canceled automation run now settles with a transcript receipt that names
the cancellation (by request, cancel timeout, or shutdown) instead of
vanishing from the live band silently. The receipt wears attention ink and
Expand Down
54 changes: 47 additions & 7 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,12 @@ pub struct Engine {
mcp_event_generation: u64,
/// Workspace-scoped immutable plugin catalogue and authority receipts.
plugin_registry: Arc<crate::plugins::PluginRegistry>,
/// Keeps the append-only `<recommended_plugins>` fragment once-per-
/// Engine-lifetime per plugin id, and suppresses plugins whose name a
/// catalogue skill already covers (#6274). The skill-name snapshot is
/// taken at construction from the same catalogue the system prompt
/// indexes (see the gate's known-limitations note).
recommended_plugin_gate: StdMutex<crate::plugins::recommend::RecommendedPluginGate>,
api_provider: ApiProvider,
/// Exact configured route key. Named custom providers share the `Custom`
/// enum, so the enum alone cannot prove that the active client is current.
Expand Down Expand Up @@ -1674,6 +1680,28 @@ impl Engine {
// `run_turn` restarts it per turn; this initial value only matters
// for hosts that inspect the engine before the first turn.
let turn_wall_clock_budget = config.turn_wall_clock;
// Skill-name snapshot for the plugin-suggestion gate (#6274): the
// SAME catalogue the system prompt indexes (prompts.rs skills block —
// workspace roots + configured skills_dir + plugin-sourced skills),
// so suppression sees everything the session actually has.
let gate_skill_names: std::collections::BTreeSet<String> =
crate::skills::discover_for_workspace_and_dir_with_mode_and_plugins(
&config.workspace,
&config.skills_dir,
crate::skills::SkillDiscoveryMode::from_codewhale_only(
config.skills_scan_codewhale_only,
),
Some(plugin_registry.as_ref()),
)
.list()
.iter()
.flat_map(|skill| {
std::iter::once(skill.name.clone()).chain(skill.aliases.iter().cloned())
})
.map(|name| name.trim().to_ascii_lowercase())
.filter(|name| !name.is_empty())
.collect();

let engine = Engine {
config,
api_config: api_config.clone(),
Expand All @@ -1698,6 +1726,11 @@ impl Engine {
mcp_boot_generation: None,
mcp_event_generation: 0,
plugin_registry,
recommended_plugin_gate: StdMutex::new(
crate::plugins::recommend::RecommendedPluginGate::with_skill_names(
gate_skill_names,
),
),
api_provider,
api_provider_identity,
api_provider_id,
Expand Down Expand Up @@ -3708,13 +3741,20 @@ impl Engine {
cache_control: None,
}];
}
let recommended_plugins = crate::plugins::recommend::recommended_plugins_user_fragment(
&text,
self.plugin_registry.as_ref(),
&crate::plugins::recommend::load_marketplace_candidates(
self.plugin_registry.state_path(),
),
);
let recommended_plugins = {
let mut recommended_plugin_gate = self
.recommended_plugin_gate
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
crate::plugins::recommend::recommended_plugins_user_fragment(
&text,
self.plugin_registry.as_ref(),
&crate::plugins::recommend::load_marketplace_candidates(
self.plugin_registry.state_path(),
),
&mut recommended_plugin_gate,
)
};
let expanded = crate::image_attach::expand_attachment_blocks(&text);
let mut content = Vec::with_capacity(3 + expanded.blocks.len());
content.push(ContentBlock::Text {
Expand Down
37 changes: 35 additions & 2 deletions crates/tui/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ use crate::utils::write_atomic;
/// Bytes of a non-2xx response body to surface in connection errors.
const ERROR_BODY_PREVIEW_BYTES: usize = 200;

/// Newest dated MCP protocol revision Codewhale advertises at `initialize` and
/// answers as an MCP server. Matches the shared MCP crate (`crates/mcp`).
pub(crate) const MCP_PROTOCOL_VERSION: &str = "2025-06-18";
/// Dated MCP revisions accepted during negotiation, newest first. A peer
/// answering or requesting any of these continues the handshake.
pub(crate) const MCP_SUPPORTED_PROTOCOL_VERSIONS: &[&str] =
&[MCP_PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];

fn validate_mcp_config_path(path: &Path) -> Result<()> {
if path.as_os_str().is_empty() {
anyhow::bail!("MCP config path cannot be empty");
Expand Down Expand Up @@ -1425,6 +1433,12 @@ pub trait McpTransport: Send + Sync {
async fn send(&mut self, msg: Vec<u8>) -> Result<()>;
async fn recv(&mut self) -> Result<Vec<u8>>;

/// Record the protocol revision negotiated at `initialize`. Only the
/// Streamable HTTP transport uses it (the `MCP-Protocol-Version` header on
/// subsequent requests); stdio and legacy SSE have no header channel, so
/// the default is a no-op.
fn set_protocol_version(&mut self, _version: &str) {}

/// Synchronous, best-effort liveness probe consulted by
/// [`McpConnection::is_ready`] so a crashed stdio child stops reading
/// as "ready" before the next call fails (#6187). Must never block and
Expand Down Expand Up @@ -1831,7 +1845,7 @@ impl McpConnection {
"id": &init_id,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"protocolVersion": MCP_PROTOCOL_VERSION,
"clientInfo": {
"name": "codewhale-tui",
"version": env!("CARGO_PKG_VERSION")
Expand All @@ -1846,11 +1860,30 @@ impl McpConnection {
.await?;

let response = self.recv(init_id).await?;
response_result(
let result = response_result(
&response,
"initialize",
self.config.reviewed_plugin.is_some(),
)?;
// Per spec, a server that cannot speak the advertised revision answers
// with one it does support. Accept any dated revision we still
// implement; anything else ends the handshake.
let negotiated = result
.and_then(|result| result.get("protocolVersion"))
.and_then(|version| version.as_str())
.ok_or_else(|| {
anyhow::anyhow!(
"MCP server '{}' initialize result omitted protocolVersion",
self.name
)
})?;
anyhow::ensure!(
MCP_SUPPORTED_PROTOCOL_VERSIONS.contains(&negotiated),
"MCP server '{}' negotiated unsupported protocol version '{negotiated}' (supported: {})",
self.name,
MCP_SUPPORTED_PROTOCOL_VERSIONS.join(", ")
);
self.transport.set_protocol_version(negotiated);
self.server_capabilities = McpServerCapabilities::from_initialize_response(&response);

// Send initialized notification (no id, no response expected)
Expand Down
8 changes: 8 additions & 0 deletions crates/tui/src/mcp/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,14 @@ impl HttpTransport {

#[async_trait::async_trait]
impl McpTransport for HttpTransport {
fn set_protocol_version(&mut self, version: &str) {
// Only Streamable HTTP carries the MCP-Protocol-Version header; the
// legacy SSE transport predates it and ignores the negotiation result.
if let HttpTransportMode::Streamable(transport) = &mut self.mode {
transport.set_protocol_version(version);
}
}

async fn send(&mut self, msg: Vec<u8>) -> Result<()> {
match &mut self.mode {
HttpTransportMode::Streamable(transport) => match transport.send(msg.clone()).await {
Expand Down
Loading
Loading