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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 44 additions & 4 deletions crates/uteke-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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")?;
Expand Down Expand Up @@ -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<String> {
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<String> = 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;
Expand Down
Loading