Skip to content
Merged
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
67 changes: 66 additions & 1 deletion src/bin/taguru-mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,14 @@ fn main() {
cancel(&tracked_calls, &active_by_id, &target_id.to_string());
continue;
}
// Checked before the tool-call fast path below, so a request
// naming an unimplemented per-request protocol version is
// refused with the supported list rather than run under
// semantics this build never promised.
if let Some(rejection) = mcp::modern_version_rejection(&message) {
emit(&stdout, &rejection);
continue;
}
match mcp::classify(&message) {
mcp::Message::Request {
id,
Expand Down Expand Up @@ -635,6 +643,10 @@ fn dispatch(bridge: &Bridge, instructions: &str, classified: mcp::Message) -> Op
mcp::initialize_result(protocol_version.as_deref(), instructions),
),
mcp::Call::Ping => mcp::response(id, serde_json::json!({})),
// 2026-07-28's mandatory RPC, and the probe a dual-era client
// sends FIRST on stdio to learn which era this server speaks —
// so it must answer even without modern `_meta` on the request.
mcp::Call::Discover => mcp::response(id, mcp::discover_result(instructions)),
mcp::Call::ToolsList => mcp::response(id, mcp::tools_result()),
mcp::Call::Tool { name, arguments } => mcp::response(
id,
Expand All @@ -650,12 +662,19 @@ fn dispatch(bridge: &Bridge, instructions: &str, classified: mcp::Message) -> Op
/// — used directly by the tests below. `main`'s loop does not call
/// this: it needs to see the classified message itself first, to queue
/// a `tools/call` for the tool worker pool instead (see `dispatch`'s
/// doc).
/// doc). This is therefore a MIRROR of that loop's gate order — batch
/// rejection, then version rejection, then dispatch — and a gate
/// added or reordered there must be reflected here in the same move,
/// or the tests keep passing against an order production no longer
/// runs.
#[cfg(test)]
fn handle(bridge: &Bridge, instructions: &str, message: &Value) -> Option<Value> {
if message.is_array() {
return Some(batch_rejected());
}
if let Some(rejection) = mcp::modern_version_rejection(message) {
return Some(rejection);
}
dispatch(bridge, instructions, mcp::classify(message))
}

Expand Down Expand Up @@ -855,6 +874,52 @@ mod tests {
);
}

/// The stdio backward-compatibility probe: a dual-era client's
/// FIRST message is `server/discover`, typically before any
/// version agreement — a server that -32601'd it would be
/// misdiagnosed as legacy-only. Answered locally, bridge untouched.
#[test]
fn server_discover_is_answered_as_the_era_probe() {
let reply = handle(
&bridge(),
"manual",
&serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "server/discover"}),
)
.expect("the era probe must be answered");
assert_eq!(
reply["result"]["supportedVersions"],
serde_json::json!(mcp::MODERN_PROTOCOL_VERSIONS),
"{reply}"
);
assert_eq!(reply["result"]["instructions"], "manual", "{reply}");
assert_eq!(reply["result"]["resultType"], "complete", "{reply}");
}

/// A request declaring a per-request protocol version this build
/// does not implement is refused with the list to retry from —
/// before dispatch, so the bridge (and the server behind it) never
/// runs work under semantics nobody agreed to.
#[test]
fn an_unimplemented_per_request_version_is_refused_with_the_supported_list() {
let reply = handle(
&bridge(),
"",
&serde_json::json!({
"jsonrpc": "2.0", "id": 3, "method": "tools/list",
"params": {"_meta": {
"io.modelcontextprotocol/protocolVersion": "2099-01-01",
}},
}),
)
.expect("an unimplemented version must be answered");
assert_eq!(reply["error"]["code"], -32022, "{reply}");
assert_eq!(
reply["error"]["data"]["supported"],
serde_json::json!(mcp::MODERN_PROTOCOL_VERSIONS),
"{reply}"
);
}

#[test]
fn a_zero_or_unparseable_timeout_falls_back_to_the_default() {
// A positive override is honored verbatim.
Expand Down
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -547,12 +547,14 @@ async fn serve(serve_args: cli::ServeArgs, auth_source: auth::AuthSource) {
move |deadline: axum::Extension<Deadline>,
key: Option<axum::Extension<auth::AuthKey>>,
scope: Option<axum::Extension<auth::KeyScope>>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes| {
remote_mcp::serve(
mcp_dispatch.clone(),
Arc::clone(&mcp_instructions),
key.map(|extension| extension.0),
scope.map(|extension| extension.0),
headers,
body,
mcp_max_result_bytes,
deadline.0,
Expand Down
164 changes: 161 additions & 3 deletions src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ mod schema;
// further out.
#[allow(unused_imports)]
pub use protocol::{
Call, FALLBACK_PROTOCOL_VERSION, Message, SUPPORTED_PROTOCOL_VERSIONS, ToolError,
cancelled_request_id, classify, error_response, initialize_result, meta_trace_headers,
response, tool_response, tools_result,
Call, FALLBACK_PROTOCOL_VERSION, HEADER_MISMATCH, META_PROTOCOL_VERSION,
MODERN_PROTOCOL_VERSIONS, Message, SUPPORTED_PROTOCOL_VERSIONS, ToolError,
cancelled_request_id, classify, discover_result, error_response, initialize_result,
meta_trace_headers, modern_version_rejection, request_protocol_version, response,
tool_response, tools_result, unsupported_version_error,
};
#[allow(unused_imports)]
pub use retrieve::{
Expand Down Expand Up @@ -1034,6 +1036,162 @@ mod tests {
);
}

/// The two version lists must stay disjoint: a version in both
/// would let `initialize` echo — and thereby promise — a wire
/// contract under which `initialize` does not exist.
#[test]
fn legacy_and_modern_version_lists_are_disjoint() {
for version in MODERN_PROTOCOL_VERSIONS {
assert!(!SUPPORTED_PROTOCOL_VERSIONS.contains(version), "{version}");
}
}

/// The regression the split guards against: a client proposing
/// 2026-07-28 through legacy `initialize` gets the newest
/// initialize-era version back, never the stateless one.
#[test]
fn initialize_never_echoes_a_modern_version() {
for version in MODERN_PROTOCOL_VERSIONS {
assert_eq!(
initialize_result(Some(version), "manual")["protocolVersion"],
FALLBACK_PROTOCOL_VERSION
);
}
}

#[test]
fn classify_routes_server_discover() {
assert!(matches!(
classify(&json!({"jsonrpc": "2.0", "id": 1, "method": "server/discover"})),
Message::Request {
call: Call::Discover,
..
}
));
}

/// The 2026-07-28 result envelope on the one RPC that revision
/// makes mandatory: versions to retry with, capabilities, cache
/// metadata, identity, and the manual.
#[test]
fn discover_result_carries_the_stateless_contract() {
let result = discover_result("manual");
assert_eq!(result["supportedVersions"], json!(MODERN_PROTOCOL_VERSIONS));
assert_eq!(result["resultType"], "complete");
assert!(result["capabilities"]["tools"].is_object(), "{result}");
assert_eq!(result["instructions"], "manual");
assert!(result["ttlMs"].is_u64(), "{result}");
assert_eq!(result["cacheScope"], "private");
assert_eq!(
result["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
"taguru"
);
}

/// `tools/list` under 2026-07-28 is a `CacheableResult`: the
/// required `ttlMs`/`cacheScope` ride alongside the tools, plus the
/// `resultType` every result now carries — all additive, so a
/// legacy client still finds `tools` exactly where it always was.
#[test]
fn tools_result_is_a_cacheable_complete_result() {
let result = tools_result();
assert!(result["tools"].is_array(), "{result}");
assert_eq!(result["resultType"], "complete");
assert!(result["ttlMs"].is_u64(), "{result}");
assert_eq!(result["cacheScope"], "private");
assert_eq!(
result["_meta"]["io.modelcontextprotocol/serverInfo"]["version"],
env!("CARGO_PKG_VERSION")
);
}

/// Every tool outcome — success and failure alike — declares
/// `resultType: "complete"`: this server never returns an interim
/// `input_required`, and a modern client must not have to guess.
#[test]
fn tool_response_declares_result_type_complete() {
assert_eq!(tool_response(Ok("fine".into()))["resultType"], "complete");
assert_eq!(
tool_response(Err("broken".to_string().into()))["resultType"],
"complete"
);
}

#[test]
fn request_protocol_version_reads_only_the_meta_key() {
assert_eq!(
request_protocol_version(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": {"_meta": {META_PROTOCOL_VERSION: "2026-07-28"}},
})),
Some("2026-07-28")
);
// No params, no _meta, or a wrong-typed value: all read as "not
// a modern request", never an error.
assert_eq!(
request_protocol_version(&json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
None
);
assert_eq!(
request_protocol_version(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": {"_meta": {META_PROTOCOL_VERSION: 2026}},
})),
None
);
}

/// The stdio-side gate: a declared-but-unimplemented per-request
/// version earns `UnsupportedProtocolVersionError` with the list to
/// retry from; a supported one, a legacy message, and a
/// notification (nothing is waiting) all pass untouched.
#[test]
fn modern_version_rejection_refuses_only_unsupported_declared_versions() {
let refused = modern_version_rejection(&json!({
"jsonrpc": "2.0", "id": 7, "method": "tools/list",
"params": {"_meta": {META_PROTOCOL_VERSION: "2099-01-01"}},
}))
.expect("an unimplemented version must be refused");
assert_eq!(refused["id"], 7);
assert_eq!(refused["error"]["code"], -32022);
assert_eq!(
refused["error"]["data"]["supported"],
json!(MODERN_PROTOCOL_VERSIONS)
);
assert_eq!(refused["error"]["data"]["requested"], "2099-01-01");

assert!(
modern_version_rejection(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": {"_meta": {META_PROTOCOL_VERSION: MODERN_PROTOCOL_VERSIONS[0]}},
}))
.is_none()
);
assert!(
modern_version_rejection(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
}))
.is_none()
);
assert!(
modern_version_rejection(&json!({
"jsonrpc": "2.0", "method": "notifications/whatever",
"params": {"_meta": {META_PROTOCOL_VERSION: "2099-01-01"}},
}))
.is_none()
);
// An id of a type JSON-RPC forbids never reaches -32022 either:
// it falls through to the ordinary InvalidId refusal, so the
// malformed id is not echoed into this error's body.
assert!(
modern_version_rejection(&json!({
"jsonrpc": "2.0", "id": [1], "method": "tools/list",
"params": {"_meta": {META_PROTOCOL_VERSION: "2099-01-01"}},
}))
.is_none()
);
}

#[test]
fn tool_response_marks_errors_without_aborting_the_rpc() {
let ok = tool_response(Ok("fine".into()));
Expand Down
Loading