Skip to content
Draft
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
5 changes: 5 additions & 0 deletions rust/crates/sift_cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ This project adheres to [Semantic Versioning](http://semver.org/).

### What's New

- MCP `list_*` tools now write `count`, `has_more`, `unmatched_fields`, and
any other metadata ahead of the items in their text result, so these keys
survive when an agent harness truncates a large result from the end.
`structuredContent` is unchanged.
- `config update` without field flags no longer prints `Nothing to update.`
and exits 0. In a terminal, it now opens an interactive editor. Outside a
terminal, it exits with status 2. Scripts that call it with no flags now
Expand Down Expand Up @@ -46,6 +50,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
model only that text, such as Claude Code, never saw the data, and agents
retried `preview_rule` expecting results.


## [v0.7.5] - October 19, 2026

### What's New
Expand Down
2 changes: 2 additions & 0 deletions rust/crates/sift_mcp/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,8 @@ Run through this before declaring the tool done:
match the current proto comments.
- [ ] Any tool taking a `filter` closes its `filter` bullet with the search block from Step 5,
adapted to that resource's own text fields and phrased as a direct instruction.
- [ ] Any `list_*` tool builds its body with `list_body` and returns it through
`list_result`, so its metadata is written ahead of the items.
- [ ] `read_only_hint` is correct, and write tools set `destructive_hint` / `idempotent_hint`
per the Step 4 mapping. Write tools confirm the destination via `next_step`.
- [ ] Every write handler gates itself as the first line: `self.require_create()?` for
Expand Down
11 changes: 5 additions & 6 deletions rust/crates/sift_mcp/src/tool/annotations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use sift_rs::{
use crate::{
error::{self, from_anyhow},
server::SiftMcpServer,
tool::common::{MetadataEntry, list_body, url_clause, with_urls},
tool::common::{MetadataEntry, list_body, list_result, url_clause, with_urls},
};

#[cfg(test)]
Expand Down Expand Up @@ -198,12 +198,11 @@ impl SiftMcpServer {
self.url_service.build_annotation_url(&a.annotation_id).ok()
})?;

Ok(CallToolResult::structured(list_body(
Ok(list_result(
list_body("annotations", annotations, fields, page.has_more),
"annotations",
annotations,
fields,
page.has_more,
)))
None,
))
}

#[tool(
Expand Down
13 changes: 6 additions & 7 deletions rust/crates/sift_mcp/src/tool/artifacts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::{
error::{self, from_anyhow},
server::SiftMcpServer,
service::artifacts::{ArtifactView, CreateArtifactInput, UpdateArtifactInput, UpdatePath},
tool::common::{MetadataEntry, list_body, to_values, url_clause},
tool::common::{MetadataEntry, list_body, list_result, to_values, url_clause},
};

#[cfg(test)]
Expand Down Expand Up @@ -442,12 +442,11 @@ impl SiftMcpServer {
})
.collect::<Result<Vec<_>, ErrorData>>()?;

Ok(CallToolResult::structured(list_body(
Ok(list_result(
list_body("artifacts", artifacts, fields, page.has_more),
"artifacts",
artifacts,
fields,
page.has_more,
)))
None,
))
}

#[tool(
Expand Down Expand Up @@ -531,7 +530,7 @@ impl SiftMcpServer {
map.insert("artifact_url".to_string(), serde_json::json!(artifact_url));
}

Ok(CallToolResult::structured(body))
Ok(list_result(body, "artifact_versions", None))
}

#[tool(
Expand Down
7 changes: 6 additions & 1 deletion rust/crates/sift_mcp/src/tool/artifacts/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use crate::{
artifacts::ArtifactListParams,
common::{
MetadataEntry, MetadataScalar,
test_support::{structured, structured_field},
test_support::{assert_keys_before, content_texts, structured, structured_field},
},
},
};
Expand Down Expand Up @@ -387,6 +387,11 @@ async fn list_artifact_versions_returns_rows_newest_first() {
}))
.await
.expect("list versions");
assert_keys_before(
&content_texts(&resp)[0],
&["artifact_url", "count", "has_more"],
"artifact_versions",
);
let body = structured(resp);
let rows = body["artifact_versions"].as_array().expect("rows");
assert_eq!(rows.len(), 2);
Expand Down
11 changes: 5 additions & 6 deletions rust/crates/sift_mcp/src/tool/assets/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use sift_rs::metadata::v1::MetadataValue;
use crate::{
error::{self, from_anyhow},
server::SiftMcpServer,
tool::common::{ListParams, MetadataEntry, list_body, url_clause, with_urls},
tool::common::{ListParams, MetadataEntry, list_body, list_result, url_clause, with_urls},
};

#[derive(Debug, Deserialize, JsonSchema)]
Expand Down Expand Up @@ -96,12 +96,11 @@ impl SiftMcpServer {
self.url_service.build_asset_url(&a.asset_id).ok()
})?;

Ok(CallToolResult::structured(list_body(
Ok(list_result(
list_body("assets", assets, fields, page.has_more),
"assets",
assets,
fields,
page.has_more,
)))
None,
))
}

#[tool(
Expand Down
17 changes: 10 additions & 7 deletions rust/crates/sift_mcp/src/tool/calculated_channels/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::{
service::calculated_channels::{
CalculatedChannelUpdate, CalculatedChannelWrite, NewCalculatedChannel,
},
tool::common::{ListParams, MetadataEntry, list_body, read_result, to_values},
tool::common::{ListParams, MetadataEntry, list_body, list_result, to_values},
};

#[cfg(test)]
Expand Down Expand Up @@ -152,12 +152,11 @@ impl SiftMcpServer {

let channels = to_values(&page.items)?;

Ok(CallToolResult::structured(list_body(
Ok(list_result(
list_body("calculated_channels", channels, fields, page.has_more),
"calculated_channels",
channels,
fields,
page.has_more,
)))
None,
))
}

#[tool(
Expand Down Expand Up @@ -252,7 +251,11 @@ impl SiftMcpServer {
);
body["next_step"] = serde_json::Value::String(next_step.clone());

Ok(read_result(body, next_step))
Ok(list_result(
body,
"calculated_channel_versions",
Some(next_step),
))
}

#[tool(
Expand Down
11 changes: 9 additions & 2 deletions rust/crates/sift_mcp/src/tool/calculated_channels/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ use super::{
};
use crate::{
server::SiftMcpServer,
tool::common::test_support::{content_texts, list_params, list_params_with_fields, structured},
tool::common::test_support::{
assert_keys_before, content_texts, list_params, list_params_with_fields, structured,
},
};

const REFERENCES_JSON: &str = r#"[
Expand Down Expand Up @@ -187,7 +189,12 @@ async fn list_calculated_channel_versions_returns_versions() {

let texts = content_texts(&resp);
assert_eq!(texts.len(), 2);
assert!(texts[1].contains("calculated_channel_versions"));
assert!(texts[0].starts_with("Listed 1 calculated channel versions."));
assert_keys_before(
&texts[1],
&["count", "has_more", "next_step"],
"calculated_channel_versions",
);

let body = structured(resp);
let versions = body["calculated_channel_versions"]
Expand Down
13 changes: 6 additions & 7 deletions rust/crates/sift_mcp/src/tool/channels/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use rmcp::{handler::server::wrapper::Parameters, model::CallToolResult, tool, tool_router};
use rmcp::{handler::server::wrapper::Parameters, tool, tool_router};

use crate::{
error::{self, from_anyhow},
server::SiftMcpServer,
tool::common::{ListParams, list_body, to_values},
tool::common::{ListParams, list_body, list_result, to_values},
};

#[cfg(test)]
Expand Down Expand Up @@ -80,11 +80,10 @@ impl SiftMcpServer {

let channels = to_values(&page.items)?;

Ok(CallToolResult::structured(list_body(
Ok(list_result(
list_body("channels", channels, fields, page.has_more),
"channels",
channels,
fields,
page.has_more,
)))
None,
))
}
}
13 changes: 12 additions & 1 deletion rust/crates/sift_mcp/src/tool/channels/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ use crate::{
server::SiftMcpServer,
service::common::DEFAULT_LIMIT,
tool::common::test_support::{
list_params, list_params_with_fields, structured, structured_field,
assert_keys_before, content_texts, list_params, list_params_with_fields, structured,
structured_field,
},
};

Expand Down Expand Up @@ -240,6 +241,16 @@ async fn list_channels_reports_a_field_that_matched_nothing() {
.await
.expect("list_channels failed");

// `channels` sorts before every metadata key, so this fails if the handler
// returns the body without `list_result`.
let texts = content_texts(&resp);
assert_eq!(texts.len(), 1);
assert_keys_before(
&texts[0],
&["count", "has_more", "unmatched_fields"],
"channels",
);

let body = structured(resp);
assert_eq!(
body["channels"],
Expand Down
86 changes: 82 additions & 4 deletions rust/crates/sift_mcp/src/tool/common/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use rmcp::ErrorData;
use rmcp::model::{CallToolResult, ContentBlock};
use rmcp::schemars::{self, JsonSchema};
use serde::ser::SerializeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sift_rs::metadata::v1::{
Expand Down Expand Up @@ -176,10 +177,14 @@ pub(crate) fn project_fields(items: Vec<Value>, fields: &[String]) -> (Vec<Value
(projected, unmatched)
}

/// The key under which `list_body` lists every field requested in `fields`
/// that matched no key on any returned item.
pub(crate) const UNMATCHED_FIELDS_KEY: &str = "unmatched_fields";

/// Build the structured body for a `list_*` tool. Projects the items when
/// `fields` is set, and reports any requested field that matched nothing, so a
/// mistyped name is visible rather than silently narrowing the response. An
/// empty `fields` array is treated as no projection.
/// `fields` is set, and reports any requested field that matched nothing under
/// [`UNMATCHED_FIELDS_KEY`], so a mistyped name is visible rather than silently
/// narrowing the response. An empty `fields` array is treated as no projection.
///
/// Every body carries `count`, the number of items in this response, and
/// `has_more`, whether the service stopped at `limit` with matches left over.
Expand Down Expand Up @@ -207,13 +212,69 @@ pub(crate) fn list_body(
body.insert(key.to_string(), Value::Array(items));
if !unmatched.is_empty() {
body.insert(
"unmatched_fields".to_string(),
UNMATCHED_FIELDS_KEY.to_string(),
Value::Array(unmatched.into_iter().map(Value::String).collect()),
);
}
Value::Object(body)
}

/// Shape a `list_*` tool's result so its metadata survives truncation. Agent
/// harnesses cut an oversized tool result from the end, and `serde_json::Map`
/// sorts keys, so `count`, `has_more` and [`UNMATCHED_FIELDS_KEY`] land after
/// the items whenever the items key sorts first (`channels`, `assets`, ...).
/// That loses them on exactly the large pages where they matter.
///
/// The text block agents read writes every key except `items_key` first, in
/// their usual order, then the items. `structuredContent` keeps `body` as is,
/// so the two differ only in key order. Pass `next_step` for a tool that also
/// leads with an instruction; it becomes its own text block ahead of the JSON,
/// as in [`read_result`].
pub(crate) fn list_result(
body: Value,
items_key: &str,
next_step: Option<String>,
) -> CallToolResult {
let text = ItemsLast {
body: &body,
items_key,
};
let text = serde_json::to_string(&text).unwrap_or_else(|_| body.to_string());
let mut content = Vec::with_capacity(2);
if let Some(next_step) = next_step {
content.push(ContentBlock::text(next_step));
}
content.push(ContentBlock::text(text));

let mut result = CallToolResult::structured(body);
result.content = content;
result
}

/// Serializes a JSON object with one key moved to the end. `SerializeMap`
/// writes entries in the order they are given, which `serde_json::Map` would
/// otherwise re-sort.
struct ItemsLast<'a> {
body: &'a Value,
items_key: &'a str,
}

impl Serialize for ItemsLast<'_> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let Value::Object(map) = self.body else {
return self.body.serialize(serializer);
};
let mut out = serializer.serialize_map(Some(map.len()))?;
for (key, value) in map.iter().filter(|(key, _)| *key != self.items_key) {
out.serialize_entry(key, value)?;
}
if let Some(items) = map.get(self.items_key) {
out.serialize_entry(self.items_key, items)?;
}
out.end()
}
}

/// Shape a read tool's result so the calling agent sees both `next_step` and
/// the payload. `CallToolResult::structured` already mirrors `body` into a text
/// content block; replacing `content` with `next_step` alone, as write tools
Expand Down Expand Up @@ -304,6 +365,23 @@ pub(crate) mod test_support {
.collect()
}

/// Assert that every key in `earlier` is written before `later` in `text`.
/// List tools write their metadata ahead of the items so it survives an
/// agent harness truncating the result from the end.
pub(crate) fn assert_keys_before(text: &str, earlier: &[&str], later: &str) {
let offset = |key: &str| {
text.find(&format!("\"{key}\":"))
.unwrap_or_else(|| panic!("missing key `{key}` in {text}"))
};
let later_at = offset(later);
for key in earlier {
assert!(
offset(key) < later_at,
"`{key}` must come before `{later}` in {text}"
);
}
}

/// Extract a single field from a tool result's structured JSON body.
pub(crate) fn structured_field(result: CallToolResult, key: &str) -> Value {
let mut value = structured(result);
Expand Down
Loading
Loading