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
2 changes: 1 addition & 1 deletion src/compile/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ pub fn generate_executor_ado_env(write_service_connection: Option<&str>) -> Stri
}

/// Safe-output names that require write access to ADO.
const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &["create-pull-request", "create-work-item", "create-wiki-page", "edit-wiki-page"];
const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &["create-pull-request", "create-work-item", "update-work-item", "create-wiki-page", "edit-wiki-page"];

/// Validate that write-requiring safe-outputs have a write service connection configured.
pub fn validate_write_permissions(front_matter: &FrontMatter) -> Result<()> {
Expand Down
27 changes: 26 additions & 1 deletion src/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::path::Path;
use crate::ndjson::{self, SAFE_OUTPUT_FILENAME};
use crate::tools::{
CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, EditWikiPageResult,
ExecutionContext, ExecutionResult, Executor,
ExecutionContext, ExecutionResult, Executor, UpdateWorkItemConfig, UpdateWorkItemResult,
};

// Re-export memory types for use by main.rs
Expand Down Expand Up @@ -80,6 +80,24 @@ pub async fn execute_safe_outputs(
info!("Found {} safe output(s) to execute", entries.len());
println!("Found {} safe output(s) to execute", entries.len());

// Pre-validate the update-work-item max constraint before executing anything
let update_wi_count = entries
.iter()
.filter(|e| e.get("name").and_then(|n| n.as_str()) == Some("update-work-item"))
.count();
if update_wi_count > 0 {
let update_config: UpdateWorkItemConfig = ctx.get_tool_config("update-work-item");
if update_wi_count > update_config.max as usize {
return Err(anyhow::anyhow!(
"Too many update-work-item safe outputs: {} found, but max is {}. \
Reduce the number of work item updates or increase 'max' in the \
safe-outputs.update-work-item configuration.",
update_wi_count,
update_config.max
));
}
}

// Log summary of what we're about to execute
for (i, entry) in entries.iter().enumerate() {
if let Some(name) = entry.get("name").and_then(|n| n.as_str()) {
Expand Down Expand Up @@ -172,6 +190,13 @@ pub async fn execute_safe_output(
);
output.execute_sanitized(ctx).await?
}
"update-work-item" => {
debug!("Parsing update-work-item payload");
let mut output: UpdateWorkItemResult = serde_json::from_value(entry.clone())
.map_err(|e| anyhow::anyhow!("Failed to parse update-work-item: {}", e))?;
debug!("update-work-item: id={}", output.id);
output.execute_sanitized(ctx).await?
}
"create-pull-request" => {
debug!("Parsing create-pull-request payload");
let mut output: CreatePrResult = serde_json::from_value(entry.clone())
Expand Down
35 changes: 35 additions & 0 deletions src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use crate::tools::{
CreateWorkItemParams, CreateWorkItemResult,
EditWikiPageParams, EditWikiPageResult, MissingDataParams, MissingDataResult,
MissingToolParams, MissingToolResult, NoopParams, NoopResult, ToolResult,
UpdateWorkItemParams, UpdateWorkItemResult,
anyhow_to_mcp_error,
};

Expand Down Expand Up @@ -335,6 +336,40 @@ impl SafeOutputs {
Ok(CallToolResult::success(vec![]))
}

#[tool(
name = "update-work-item",
description = "Update an existing Azure DevOps work item. Only fields explicitly enabled \
in the pipeline configuration (safe-outputs.update-work-item) may be changed. Updates may be \
further restricted by target (only a specific work item ID) or title-prefix (only work items \
whose current title starts with a configured prefix). Provide the work item ID and only the \
fields you want to update."
)]
async fn update_work_item(
&self,
params: Parameters<UpdateWorkItemParams>,
) -> Result<CallToolResult, McpError> {
info!("Tool called: update-work-item - id={}", params.0.id);
// Sanitize untrusted agent-provided text fields (IS-01)
let mut sanitized = params.0;
sanitized.title = sanitized.title.map(|t| sanitize_text(&t));
sanitized.body = sanitized.body.map(|b| sanitize_text(&b));
sanitized.state = sanitized.state.map(|s| sanitize_text(&s));
sanitized.work_item_type = sanitized.work_item_type.map(|t| sanitize_text(&t));
sanitized.area_path = sanitized.area_path.map(|p| sanitize_text(&p));
sanitized.iteration_path = sanitized.iteration_path.map(|p| sanitize_text(&p));
sanitized.assignee = sanitized.assignee.map(|a| sanitize_text(&a));
sanitized.tags = sanitized
.tags
.map(|ts| ts.into_iter().map(|t| sanitize_text(&t)).collect());
let result: UpdateWorkItemResult = sanitized.try_into()?;
let _ = self.write_safe_output_file(&result).await;
info!("Work item update queued for #{}", result.id);
Ok(CallToolResult::success(vec![Content::text(format!(
"Work item #{} update queued. Changes will be applied during safe output processing.",
result.id
))]))
}

#[tool(
name = "create-pull-request",
description = "Create a new pull request to propose code changes. Use this after making file edits to submit them for review and merging. The PR will be created from the current branch with your committed changes. Use 'self' for the pipeline's own repository, or a repository alias from the checkout list."
Expand Down
2 changes: 2 additions & 0 deletions src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ mod missing_data;
mod missing_tool;
mod noop;
mod result;
mod update_work_item;

pub use create_pr::*;
pub use create_wiki_page::*;
Expand All @@ -29,3 +30,4 @@ pub use noop::*;
pub use result::{
ExecutionContext, ExecutionResult, Executor, ToolResult, Validate, anyhow_to_mcp_error,
};
pub use update_work_item::*;
Loading