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
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ include_dir = "0.7"
indicatif = "0.18"
indoc = "2.0"
mcap = "0.25"
mime_guess = "2"
parquet = "58.3.0"
percent-encoding = "2"
prost = "^0.14"
Expand All @@ -65,7 +66,7 @@ pyo3-async-runtimes = { version = "0.28", features = ["tokio-runtime"] }
pyo3-stub-gen = "0.10"
rand = "0.10"
reqwest = "0.13"
rmcp = "3.1.2"
rmcp = "3.2.0"
semver = "1.0"
serde = "^1.0"
serde_json = "^1.0"
Expand Down
7 changes: 7 additions & 0 deletions rust/crates/sift_cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ This project adheres to [Semantic Versioning](http://semver.org/).
importing metadata records as run metadata (`--metadata-record`),
recoverable-error handling (`--parse-error-policy`), and how
variable-cardinality fields are imported (`--complex-types-import-mode`).
- `create_artifact` accepts a `file_path` and uploads the file as the new
version's content (streamed to Sift's file store, up to 1 GiB). The response
then carries the file's name, mime type, `remote_file_id`, and a signed
`download_url`.
- Updated `rmcp` to 3.2.0. A client that names protocol version `2026-07-28`
over a legacy `initialize` handshake is now answered with `2025-11-25`, as
that revision has no handshake; stateless 2026 clients are unaffected.
- Added artifact MCP tools: `list_artifacts`, `download_artifact`, and
`create_artifact`, backed by public `sift.artifacts.v1.ArtifactService`.
`create_artifact` writes artifact metadata (and can link a new artifact to a
Expand Down
9 changes: 6 additions & 3 deletions rust/crates/sift_cli/assets/skills/sift/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,13 @@ exists.
Send a rename on its own. The API applies a `name` change by itself and
ignores every other field, so `update_user_defined_function` rejects `name`
combined with anything else.
- **Create an artifact.** `create_artifact` with a `title` / `summary`.
Pass `conversation_id` to link it to a chat, and `authoring_kind=agent` when
- **Create an artifact.** `create_artifact` with a `title` / `summary`, and
Comment thread
evan-sift marked this conversation as resolved.
`file_path` pointing at the local file that is the artifact's content — an
artifact without a file has nothing to preview or download. Pass
`conversation_id` to link it to a chat, and `authoring_kind=agent` when
a Sift agent produced it. Append a version by passing the existing
`artifact_id`. Creating is gated by `--allow-create`; appending a version to
`artifact_id`. One artifact per real deliverable; never one per scratch
file. Creating is gated by `--allow-create`; appending a version to
an existing artifact also needs `--allow-destructive`. Discover artifacts
with `list_artifacts` (oldest first, no `order_by`); fetch a version or its
`download_url` with `download_artifact`.
Expand Down
2 changes: 2 additions & 0 deletions rust/crates/sift_cli/src/cmd/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ pub async fn run(ctx: Context, args: McpArgs, app_uri: String) -> Result<ExitCod
sift_mcp::FeatureFlags::default()
});

let rest_config = sift_mcp::RestConfig::new(ctx.rest_uri.clone(), ctx.api_key.clone());
let credentials = Credentials::Config {
uri: ctx.grpc_uri,
apikey: ctx.api_key,
Expand All @@ -72,6 +73,7 @@ pub async fn run(ctx: Context, args: McpArgs, app_uri: String) -> Result<ExitCod
update_check,
client_event_config,
feature_flags,
Some(rest_config),
)
.await
{
Expand Down
3 changes: 2 additions & 1 deletion rust/crates/sift_mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ arrow.workspace = true
parquet.workspace = true
polars = { workspace = true, features = ["lazy", "parquet", "sql"] }
tokio-stream.workspace = true
reqwest = { workspace = true, features = ["json"] }
reqwest = { workspace = true, features = ["json", "multipart", "stream"] }
mime_guess.workspace = true

[dev-dependencies]
sift_test_util.workspace = true
Expand Down
6 changes: 6 additions & 0 deletions rust/crates/sift_mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub use client_event::ClientEventConfig;

mod feature_flags;
pub use feature_flags::FeatureFlags;
pub use service::remote_files::RestConfig;

mod server;
use server::SiftMcpServer;
Expand Down Expand Up @@ -113,6 +114,7 @@ pub async fn run_with_update_check(
update_check,
None,
FeatureFlags::default(),
None,
)
.await
}
Expand All @@ -131,6 +133,7 @@ pub async fn run_with_client_events(
update_check: Option<UpdateCheckReceiver>,
client_event_config: Option<ClientEventConfig>,
feature_flags: FeatureFlags,
rest_config: Option<RestConfig>,
) -> Result<()> {
let client_event_reporter =
client_event::ClientEventReporter::from_config(client_event_config, &cli_version);
Expand All @@ -145,6 +148,7 @@ pub async fn run_with_client_events(
update_check,
client_event_reporter,
feature_flags,
rest_config,
},
)
.await
Expand All @@ -158,6 +162,7 @@ struct RunConfig {
update_check: Option<UpdateCheckReceiver>,
client_event_reporter: client_event::ClientEventReporter,
feature_flags: FeatureFlags,
rest_config: Option<RestConfig>,
}

async fn run_server(credentials: Credentials, use_tls: bool, config: RunConfig) -> Result<()> {
Expand All @@ -176,6 +181,7 @@ async fn run_server(credentials: Credentials, use_tls: bool, config: RunConfig)
config.update_check,
config.client_event_reporter,
config.feature_flags,
config.rest_config,
)
.serve(stdio())
.await
Expand Down
19 changes: 18 additions & 1 deletion rust/crates/sift_mcp/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,20 @@ impl SiftMcpServer {
Some(update_check),
ClientEventReporter::default(),
FeatureFlags::default(),
None,
)
}

/// Test-only: route artifact file uploads at a mock REST endpoint.
#[cfg(test)]
pub fn with_artifact_uploader(
mut self,
uploader: crate::service::remote_files::RemoteFileUploader,
) -> Self {
self.artifact_service = self.artifact_service.with_uploader(uploader);
self
}

#[allow(clippy::too_many_arguments)]
pub(crate) fn new_with_client_events(
channel: SiftChannel,
Expand All @@ -183,6 +194,7 @@ impl SiftMcpServer {
update_check: Option<UpdateCheckReceiver>,
client_event_reporter: ClientEventReporter,
feature_flags: FeatureFlags,
rest_config: Option<crate::service::remote_files::RestConfig>,
) -> Self {
// Add more routers here as new tool groups are introduced, e.g.
// tool_router.merge(Self::ingestion_router())
Expand Down Expand Up @@ -217,7 +229,12 @@ impl SiftMcpServer {
let retry_policy = RetryPolicy::default();

let annotation_service = AnnotationService::new(channel.clone(), retry_policy.clone());
let artifact_service = ArtifactService::new(channel.clone(), retry_policy.clone());
let mut artifact_service = ArtifactService::new(channel.clone(), retry_policy.clone());
if let Some(rest_config) = rest_config {
artifact_service = artifact_service.with_uploader(
crate::service::remote_files::RemoteFileUploader::new(rest_config, &cli_version),
);
}
let asset_service = AssetService::new(channel.clone(), retry_policy.clone());
let calculated_channel_service =
CalculatedChannelService::new(channel.clone(), retry_policy.clone());
Expand Down
22 changes: 18 additions & 4 deletions rust/crates/sift_mcp/src/server/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ async fn server_with_feature_flags(
update_check,
client_event_reporter,
feature_flags,
None,
),
handle,
)
Expand Down Expand Up @@ -392,6 +393,15 @@ fn modern_request(id: u64, method: &str) -> Value {
})
}

/// A pre-2026 list result: a plain item array with none of the 2026 result
/// envelope fields.
fn assert_legacy_list_result<'a>(response: &'a Value, item_key: &str) -> &'a [Value] {
assert!(response["result"].get("resultType").is_none(), "{response}");
assert!(response["result"].get("ttlMs").is_none(), "{response}");
assert!(response["result"].get("cacheScope").is_none(), "{response}");
response["result"][item_key].as_array().unwrap()
}

fn assert_modern_list_result<'a>(response: &'a Value, item_key: &str) -> &'a [Value] {
assert_eq!(response["result"]["resultType"], "complete");
assert_eq!(response["result"]["ttlMs"], 0);
Expand Down Expand Up @@ -738,8 +748,12 @@ async fn disabled_update_check_is_not_advertised() {
finish(reader, writer, server).await;
}

/// `2026-07-28` has no `initialize` handshake, so rmcp (3.2.0+) answers a
/// client that names it over `initialize` with the newest legacy version and
/// serves the session in that shape. Stateless 2026 clients carry the version
/// in per-request `_meta` instead; see the `stateless_2026_*` tests.
#[tokio::test]
async fn claude_legacy_handshake_gets_complete_2026_list_results() {
async fn claude_legacy_handshake_naming_2026_is_negotiated_down() {
let current = UpdateCheck::Current {
current_version: "0.4.0".to_string(),
latest_version: "0.4.0".to_string(),
Expand All @@ -749,7 +763,7 @@ async fn claude_legacy_handshake_gets_complete_2026_list_results() {
.await;

let initialize = read_json(&mut reader).await;
assert_eq!(initialize["result"]["protocolVersion"], "2026-07-28");
assert_eq!(initialize["result"]["protocolVersion"], "2025-11-25");

writer
.write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n")
Expand All @@ -760,7 +774,7 @@ async fn claude_legacy_handshake_gets_complete_2026_list_results() {
.await
.unwrap();
let tools = read_json(&mut reader).await;
let tools = assert_modern_list_result(&tools, "tools");
let tools = assert_legacy_list_result(&tools, "tools");
assert!(tools.iter().any(|tool| tool["name"] == "list_assets"));
assert!(tools.iter().any(|tool| tool["name"] == "check_for_updates"));

Expand All @@ -769,7 +783,7 @@ async fn claude_legacy_handshake_gets_complete_2026_list_results() {
.await
.unwrap();
let prompts = read_json(&mut reader).await;
let prompts = assert_modern_list_result(&prompts, "prompts");
let prompts = assert_legacy_list_result(&prompts, "prompts");
assert!(
prompts
.iter()
Expand Down
68 changes: 65 additions & 3 deletions rust/crates/sift_mcp/src/service/artifacts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ use sift_rs::{
},
};

use std::path::Path;

use crate::policy::{RetryPolicy, with_retry};
use crate::service::common;
use crate::service::remote_files::RemoteFileUploader;

#[cfg(test)]
mod test;
Expand All @@ -30,11 +33,23 @@ pub struct ArtifactView {
pub struct ArtifactService {
channel: SiftChannel,
policy: RetryPolicy,
// Absent only when the server runs without a REST endpoint (some tests);
// creating an artifact with a file requires it.
uploader: Option<RemoteFileUploader>,
}

impl ArtifactService {
pub fn new(channel: SiftChannel, policy: RetryPolicy) -> Self {
Self { channel, policy }
Self {
channel,
policy,
uploader: None,
}
}

pub fn with_uploader(mut self, uploader: RemoteFileUploader) -> Self {
self.uploader = Some(uploader);
self
}

pub async fn list_artifacts(
Expand Down Expand Up @@ -121,7 +136,17 @@ impl ArtifactService {
conversation_id: Option<String>,
artifact_id: Option<String>,
authoring_kind: ArtifactAuthoringKind,
file_path: Option<&Path>,
) -> Result<ArtifactView> {
// Refuse before creating any rows, so a misconfigured server does not
// leave a byteless version behind.
let uploader = match file_path {
Some(_) => Some(self.uploader.as_ref().context(
"this server was started without a REST endpoint, so `file_path` is not supported",
)?),
None => None,
};

let channel = self.channel.clone();
let created = with_retry(&self.policy, move || {
let channel = channel.clone();
Expand All @@ -148,9 +173,46 @@ impl ArtifactService {
.artifact
.ok_or_else(|| anyhow!("create artifact response missing artifact"))?;

let (Some(uploader), Some(path)) = (uploader, file_path) else {
return Ok(ArtifactView {
inner: created,
download_url: None,
});
};

// The version row exists from here on: a failed upload must say so,
// or the agent will retry the create and mint a duplicate artifact.
let upload_context = format!(
"artifact {} version {} was created, but uploading `{}` failed; do NOT create the artifact again",
created.artifact_id,
created.version,
path.display()
);
uploader
.upload_artifact_version_file(
&created.organization_id,
&created.artifact_version_id,
path,
)
.await
.context(upload_context)?;

// Refresh so the returned artifact carries the uploaded file's name,
// mime type, and remote_file_id, and mint the download link.
let refreshed = self
.get_artifact(
created.artifact_id.clone(),
Some(created.artifact_version_id.clone()),
)
.await
.unwrap_or(created);
let download_url = match refreshed.remote_file_id.clone() {
Some(remote_file_id) => self.download_url(remote_file_id).await.ok(),
None => None,
};
Ok(ArtifactView {
inner: created,
download_url: None,
inner: refreshed,
download_url,
})
}

Expand Down
1 change: 1 addition & 0 deletions rust/crates/sift_mcp/src/service/artifacts/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ async fn create_artifact_returns_created_row() {
Some("conv-1".into()),
None,
ArtifactAuthoringKind::Agent,
None,
)
.await
.expect("create");
Expand Down
1 change: 1 addition & 0 deletions rust/crates/sift_mcp/src/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod data;
pub mod docs;
pub mod ingest;
pub mod ping;
pub mod remote_files;
pub mod report_templates;
pub mod reports;
pub mod rule_evaluation;
Expand Down
Loading
Loading