From cc35350ba5725633f083024bf17678ca33873dfe Mon Sep 17 00:00:00 2001 From: ajianaz Date: Thu, 15 Oct 2026 16:07:30 +0700 Subject: [PATCH] fix(mcp): tools/list shows a read-only caller only the read tools (#1446) --- CHANGELOG.md | 4 ++++ crates/uteke-mcp/src/lib.rs | 48 +++++++++++++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05a4b9f3..390be883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- **`tools/list` for a read-only token lists only the tools it can call (#1446)** - MCP over HTTP advertised every tool to read-only callers, which then failed at call time. A read-only caller now sees exactly the read tools; full-access callers, the stdio transport and `tools/call` are unchanged. Contract: uteke-cloud#203 (approved by the owner). + ## [0.21.1] - 2026-10-15 Patch release. Theme: **`uteke upgrade` verifies a minisign signature, Homebrew diff --git a/crates/uteke-mcp/src/lib.rs b/crates/uteke-mcp/src/lib.rs index d7fbbf11..a6b59dc5 100644 --- a/crates/uteke-mcp/src/lib.rs +++ b/crates/uteke-mcp/src/lib.rs @@ -193,8 +193,8 @@ fn handle_request( "notifications/initialized" => Ok(Value::Null), - "tools/list" => Ok(serde_json::json!({ - "tools": [ + "tools/list" => { + let mut tools = vec![ tool_remember(), tool_recall(), tool_search(), @@ -244,8 +244,18 @@ fn handle_request( tool_namespace_delete(), tool_pin(), tool_unpin(), - ] - })), + ]; + // A read-only caller sees only the tools it can call (#1446); + // tools/call keeps its own gate below. + if access == Access::ReadOnly { + tools.retain(|t| { + t["name"] + .as_str() + .is_some_and(|n| READ_ONLY_TOOLS.contains(&n)) + }); + } + Ok(serde_json::json!({ "tools": tools })) + } "tools/call" => { let params = params.ok_or("Missing params for tools/call")?; @@ -3769,6 +3779,36 @@ mod namespace_deprecation_tests { assert!(t.contains(NAMESPACE_DEPRECATION_WARNING), "{t}"); } + #[test] + fn read_only_tools_list_is_exactly_the_read_tools() { + let u = store(); + let names = |access| -> std::collections::BTreeSet { + handle_request(&u, "tools/list", None, access).unwrap()["tools"] + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap().to_string()) + .collect() + }; + let read_only = names(Access::ReadOnly); + // `uteke_room_document` is a dispatch alias that tools/list never advertises. + let expected: std::collections::BTreeSet = READ_ONLY_TOOLS + .iter() + .filter(|n| **n != "uteke_room_document") + .map(|n| n.to_string()) + .collect(); + assert_eq!(read_only, expected); + let full = names(Access::Full); + assert!(read_only.is_subset(&full)); + assert_eq!(full.len(), 49, "full catalog must not change"); + for write in ["uteke_remember", "uteke_forget", "uteke_doc_create"] { + assert!( + full.contains(write) && !read_only.contains(write), + "{write}" + ); + } + } + #[test] fn every_tool_with_a_namespace_argument_says_deprecated() { let mut checked = 0;