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
246 changes: 246 additions & 0 deletions protos/sift/artifacts/v1/artifacts.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
syntax = "proto3";

package sift.artifacts.v1;

import "google/api/annotations.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "google/protobuf/timestamp.proto";

option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
info: {title: "Artifact Service"}
};

// ArtifactService manages artifacts: independent, org-scoped, versioned
// documents. An artifact is not owned by a conversation. A conversation may
// reference an artifact through a link, any number of conversations may link
// the same artifact, and an artifact may exist with no link at all (for
// example, created over MCP outside an agent session).
//
// The service carries artifact metadata only. Version bytes live in
// remote_files (entity_type 'artifact_versions', entity_id =
// artifact_version_id): upload via the remote-files multipart endpoint,
// download via RemoteFileService.GetRemoteFileDownloadUrl.
service ArtifactService {
// Creates an artifact plus its version-1 row, or appends a version when
// artifact_id is set. When conversation_id is set on create, the new
// artifact is also linked to that conversation.
rpc CreateArtifact(CreateArtifactRequest) returns (CreateArtifactResponse) {
option (google.api.http) = {
post: "/api/v1/artifacts"
body: "*"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "CreateArtifact"
description: "Create a new artifact or append a version to an existing one."
operation_id: "ArtifactService_CreateArtifactV1"
};
}

// Resolves to the latest version unless artifact_version_id pins one.
rpc GetArtifact(GetArtifactRequest) returns (GetArtifactResponse) {
option (google.api.http) = {get: "/api/v1/artifacts/{artifact_id}"};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "GetArtifact"
description: "Get one artifact, resolved to the latest version unless artifact_version_id pins one."
operation_id: "ArtifactService_GetArtifactV1"
};
}

// One entry per artifact, resolved to its latest version, oldest first.
// With conversation_id set, only artifacts linked to that conversation;
// otherwise every artifact created by the caller.
rpc ListArtifacts(ListArtifactsRequest) returns (ListArtifactsResponse) {
option (google.api.http) = {get: "/api/v1/artifacts"};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "ListArtifacts"
description: "List artifacts created by the caller, optionally filtered to one conversation."
operation_id: "ArtifactService_ListArtifactsV1"
};
}

// Full version history of one artifact, newest first.
rpc ListArtifactVersions(ListArtifactVersionsRequest) returns (ListArtifactVersionsResponse) {
option (google.api.http) = {get: "/api/v1/artifacts/{artifact_id}/versions"};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "ListArtifactVersions"
description: "List the version history of one artifact, newest first."
operation_id: "ArtifactService_ListArtifactVersionsV1"
};
}

// Links an existing artifact to a conversation. Idempotent: linking an
// already-linked pair succeeds without effect.
rpc LinkArtifactToConversation(LinkArtifactToConversationRequest) returns (LinkArtifactToConversationResponse) {
option (google.api.http) = {
post: "/api/v1/artifacts/{artifact_id}/conversations/{conversation_id}:link"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "LinkArtifactToConversation"
description: "Link an existing artifact to a conversation."
operation_id: "ArtifactService_LinkArtifactToConversationV1"
};
}

// Removes one conversation's link to an artifact. The artifact itself is
// untouched. Idempotent: unlinking a missing link succeeds.
rpc UnlinkArtifactFromConversation(UnlinkArtifactFromConversationRequest) returns (UnlinkArtifactFromConversationResponse) {
option (google.api.http) = {
post: "/api/v1/artifacts/{artifact_id}/conversations/{conversation_id}:unlink"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "UnlinkArtifactFromConversation"
description: "Remove a conversation's link to an artifact."
operation_id: "ArtifactService_UnlinkArtifactFromConversationV1"
};
}

// Sets archived_date. Versions, stored bytes, and conversation links are
// left in place. Idempotent: archiving an already-archived artifact
// succeeds without changing archived_date.
rpc ArchiveArtifact(ArchiveArtifactRequest) returns (ArchiveArtifactResponse) {
option (google.api.http) = {post: "/api/v1/artifacts/{artifact_id}/archive"};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "ArchiveArtifact"
description: "Archive an artifact. Versions, stored bytes, and conversation links are left in place."
operation_id: "ArtifactService_ArchiveArtifactV1"
};
}

// Clears archived_date. Idempotent: unarchiving an active artifact
// succeeds without effect.
rpc UnarchiveArtifact(UnarchiveArtifactRequest) returns (UnarchiveArtifactResponse) {
option (google.api.http) = {post: "/api/v1/artifacts/{artifact_id}/unarchive"};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "UnarchiveArtifact"
description: "Unarchive an artifact."
operation_id: "ArtifactService_UnarchiveArtifactV1"
};
}
}

enum ArtifactAuthoringKind {
ARTIFACT_AUTHORING_KIND_UNSPECIFIED = 0;
ARTIFACT_AUTHORING_KIND_AGENT = 1;
ARTIFACT_AUTHORING_KIND_USER = 2;
}

// Container fields flattened together with one resolved version.
message Artifact {
string artifact_id = 1;
string organization_id = 2;
string created_by_user_id = 3;
ArtifactAuthoringKind authoring_kind = 5;
google.protobuf.Timestamp created_date = 6;
string artifact_version_id = 7;
uint32 version = 8;
optional string title = 9;
optional string summary = 10;
// Set only for versions authored inside a conversation, and there only
// after the introducing message persists: uploads attach before the user
// sends, and agent turns persist messages only at end of turn.
optional string authoring_message_id = 11;
repeated string source_tool_use_ids = 12;
// Unset until bytes are uploaded.
optional string remote_file_id = 13;
google.protobuf.Timestamp version_created_date = 14;
// From the version's remote_files row. Unset until bytes are uploaded.
// Clients infer preview behavior from file_name / file_mime_type.
optional string file_name = 15;
optional string file_mime_type = 16;
// Unset while the artifact is active.
optional google.protobuf.Timestamp archived_date = 17;
}

message ArtifactVersion {
string artifact_version_id = 1;
string artifact_id = 2;
uint32 version = 3;
optional string title = 4;
optional string summary = 5;
optional string authoring_message_id = 6;
repeated string source_tool_use_ids = 7;
optional string remote_file_id = 8;
google.protobuf.Timestamp created_date = 9;
// From the version's remote_files row. Unset until bytes are uploaded.
optional string file_name = 10;
optional string file_mime_type = 11;
}

message CreateArtifactRequest {
// Set to append a version to an existing artifact; unset to create one.
optional string artifact_id = 1;
// Set to link the new artifact to a conversation at create time. Legal
// only on create (not on append), and only for the conversation's author.
optional string conversation_id = 2;
optional string title = 4;
optional string summary = 5;
// Defaults to USER. AGENT is self-reported: the agent pod authenticates
// with the requesting user's transient key, so until scoped pod
// credentials land (ENG-12831) authorship attribution rides on the same
// trust as the pod's message persistence path.
optional ArtifactAuthoringKind authoring_kind = 6;
}

message CreateArtifactResponse {
Artifact artifact = 1;
}

message GetArtifactRequest {
string artifact_id = 1;
optional string artifact_version_id = 2;
}

message GetArtifactResponse {
Artifact artifact = 1;
}

message ListArtifactsRequest {
// Unset lists every artifact created by the caller.
optional string conversation_id = 1;
uint32 page_size = 2;
string page_token = 3;
// When false (the default), archived artifacts are omitted.
bool include_archived = 4;
}

message ListArtifactsResponse {
repeated Artifact artifacts = 1;
string next_page_token = 2;
}

message ListArtifactVersionsRequest {
string artifact_id = 1;
uint32 page_size = 2;
string page_token = 3;
}

message ListArtifactVersionsResponse {
repeated ArtifactVersion versions = 1;
string next_page_token = 2;
}

message LinkArtifactToConversationRequest {
string artifact_id = 1;
string conversation_id = 2;
}

message LinkArtifactToConversationResponse {}

message UnlinkArtifactFromConversationRequest {
string artifact_id = 1;
string conversation_id = 2;
}

message UnlinkArtifactFromConversationResponse {}

message ArchiveArtifactRequest {
string artifact_id = 1;
}

message ArchiveArtifactResponse {}

message UnarchiveArtifactRequest {
string artifact_id = 1;
}

message UnarchiveArtifactResponse {}
8 changes: 8 additions & 0 deletions rust/crates/sift_cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ This project adheres to [Semantic Versioning](http://semver.org/).

### What's New

- 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
conversation). Creating requires `--allow-create`; appending a version to an
existing artifact also requires `--allow-destructive`. `download_artifact` returns
a `download_url` when the version has uploaded bytes and fails instead of
returning a partial artifact when that URL cannot be minted. These tools are
enabled per account by the agents feature flag, resolved at server startup.
- Some MCP tools are now enabled per account by feature flags resolved at server
startup. A tool absent from the tool list needs its account flag enabled and an
MCP restart.
Expand Down
17 changes: 15 additions & 2 deletions rust/crates/sift_cli/assets/skills/sift/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ exists.
started with `--disable-update-check` omit this tool. `ping` is a
connectivity check; when it fails, expect every other Sift tool to fail too.
- **Discovery:** `list_assets`, `list_runs`, `list_channels`, `list_reports`,
`list_report_templates`, `list_rules`, `list_rule_versions`, `list_annotations`.
`list_report_templates`, `list_rules`, `list_rule_versions`, `list_annotations`,
`list_artifacts`.
- **Artifacts:** `download_artifact` (latest version, or pin `artifact_version_id`).
The artifact tools, including `list_artifacts` and the `create_artifact`
write below, are enabled per account by the agents feature flag resolved when
the MCP server starts, so they may be absent from the tool list. Enabling
them requires an account setting and an MCP restart.
- **Derived channels:** `list_calculated_channels`,
`list_calculated_channel_versions`, `list_user_defined_functions`,
`list_user_defined_function_versions`.
Expand Down Expand Up @@ -81,7 +87,7 @@ exists.
`create_user_defined_function`, `update_user_defined_function`,
`archive_user_defined_function`, `unarchive_user_defined_function`,
`create_test_report`, `append_test_measurements`, `update_asset`,
`update_run`.
`update_run`, `create_artifact`.

## Workflows that span tools

Expand Down Expand Up @@ -134,6 +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
a Sift agent produced it. Append a version by passing the existing
`artifact_id`. 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`.
- **Produce a chart.** Build a link with `explore_url`. When the user wants a
chart and numbers, do both and give the user both.
- **Answer a question about how Sift works.** Call `search_docs`. Do not answer
Expand Down
3 changes: 3 additions & 0 deletions rust/crates/sift_mcp/src/feature_flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ pub(crate) static TOOL_FEATURE_FLAGS: &[(&str, &str)] = &[
("count_test_measurements", "test-reports"),
("create_test_report", "test-reports"),
("append_test_measurements", "test-reports"),
("list_artifacts", "chat-agents-service"),
("download_artifact", "chat-agents-service"),
("create_artifact", "chat-agents-service"),
];

#[derive(Clone, Debug, Default, Deserialize)]
Expand Down
10 changes: 7 additions & 3 deletions rust/crates/sift_mcp/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,13 @@ pub(crate) const BASE_INSTRUCTIONS: &str = concat!(
"rules: fields at their default value (false, 0, empty string/list) are ",
"omitted, so a missing boolean key means false, not unknown."
);
use crate::service::test_reports::TestReportService;
use crate::service::{
annotations::AnnotationService, assets::AssetService,
annotations::AnnotationService, artifacts::ArtifactService, assets::AssetService,
calculated_channels::CalculatedChannelService, channels::ChannelService, data::DataService,
docs::DocsService, ingest::IngestService, ping::PingService,
report_templates::ReportTemplateService, reports::ReportService,
rule_evaluation::RuleEvaluationService, rules::RuleService, runs::RunService, url::UrlService,
rule_evaluation::RuleEvaluationService, rules::RuleService, runs::RunService,
test_reports::TestReportService, url::UrlService,
user_defined_functions::UserDefinedFunctionService, users::UserService,
};

Expand All @@ -55,6 +55,7 @@ pub struct SiftMcpServer {
pub prompt_router: PromptRouter<Self>,

pub annotation_service: AnnotationService,
pub artifact_service: ArtifactService,
pub asset_service: AssetService,
pub calculated_channel_service: CalculatedChannelService,
pub channel_service: ChannelService,
Expand Down Expand Up @@ -197,6 +198,7 @@ impl SiftMcpServer {
tool_router.merge(Self::rules_router());
tool_router.merge(Self::rule_evaluation_router());
tool_router.merge(Self::annotations_router());
tool_router.merge(Self::artifacts_router());
tool_router.merge(Self::test_reports_router());
tool_router.merge(Self::docs_router());
tool_router.merge(Self::user_defined_functions_router());
Expand All @@ -215,6 +217,7 @@ 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 asset_service = AssetService::new(channel.clone(), retry_policy.clone());
let calculated_channel_service =
CalculatedChannelService::new(channel.clone(), retry_policy.clone());
Expand All @@ -238,6 +241,7 @@ impl SiftMcpServer {

Self {
annotation_service,
artifact_service,
asset_service,
calculated_channel_service,
channel_service,
Expand Down
21 changes: 21 additions & 0 deletions rust/crates/sift_mcp/src/server/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,27 @@ async fn feature_flag_gates_test_report_tools() {
enabled_handle.abort();
}

#[tokio::test]
async fn each_feature_flag_gates_only_its_own_tools() {
for &(_, flag) in TOOL_FEATURE_FLAGS {
let flags: FeatureFlags =
serde_json::from_value(serde_json::json!({ "variants": { flag: { "value": "on" } } }))
.unwrap();
let (server, handle) =
server_with_feature_flags(None, 0, ClientEventReporter::default(), flags).await;
let routed = server.tool_router.list_all();
for &(tool_name, tool_flag) in TOOL_FEATURE_FLAGS {
let is_routed = routed.iter().any(|tool| tool.name == tool_name);
assert_eq!(
is_routed,
tool_flag == flag,
"with only `{flag}` enabled, `{tool_name}` routed = {is_routed}"
);
}
handle.abort();
}
}

async fn initialized_client_with_feature_flags(
feature_flags: FeatureFlags,
) -> (
Expand Down
Loading
Loading