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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ serde_yaml = "0.9.34"
serde_json = "1.0.149"
schemars = "1.2"
rmcp = { version = "0.8.0", features = ["server", "transport-io"] }
percent-encoding = "2.3"
reqwest = { version = "0.12", features = ["json"] }
tempfile = "3"
tokio = { version = "1.43", features = ["full"] }
Expand Down
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"];
const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &["create-pull-request", "create-work-item", "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
56 changes: 55 additions & 1 deletion src/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ use std::path::Path;

use crate::ndjson::{self, SAFE_OUTPUT_FILENAME};
use crate::tools::{
CreatePrResult, CreateWorkItemResult, ExecutionContext, ExecutionResult, Executor,
CreatePrResult, CreateWorkItemResult, EditWikiPageResult, ExecutionContext, ExecutionResult,
Executor,
};

// Re-export memory types for use by main.rs
Expand Down Expand Up @@ -181,6 +182,17 @@ pub async fn execute_safe_output(
);
output.execute_sanitized(ctx).await?
}
"edit-wiki-page" => {
debug!("Parsing edit-wiki-page payload");
let mut output: EditWikiPageResult = serde_json::from_value(entry.clone())
.map_err(|e| anyhow::anyhow!("Failed to parse edit-wiki-page: {}", e))?;
debug!(
"edit-wiki-page: path='{}', content length={}",
output.path,
output.content.len()
);
output.execute_sanitized(ctx).await?
}
"noop" => {
debug!("Skipping noop entry");
ExecutionResult::success("Skipped informational output: noop")
Expand Down Expand Up @@ -371,4 +383,46 @@ mod tests {
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("evil-backdoor"));
}

#[tokio::test]
async fn test_execute_malformed_edit_wiki_page_returns_err() {
// Missing required fields (path and content)
let entry = serde_json::json!({"name": "edit-wiki-page"});
let ctx = ExecutionContext::default();

let result = execute_safe_output(&entry, &ctx).await;
assert!(result.is_err());
}

#[tokio::test]
async fn test_execute_edit_wiki_page_missing_context() {
let entry = serde_json::json!({
"name": "edit-wiki-page",
"path": "/Overview",
"content": "This is some valid wiki content."
});

// Context without required fields (ado_org_url, etc.)
let ctx = ExecutionContext {
ado_org_url: None,
ado_organization: None,
ado_project: None,
access_token: None,
working_directory: PathBuf::from("."),
source_directory: PathBuf::from("."),
tool_configs: HashMap::new(),
repository_id: None,
repository_name: None,
allowed_repositories: HashMap::new(),
};

let result = execute_safe_output(&entry, &ctx).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("AZURE_DEVOPS_ORG_URL")
);
}
}
41 changes: 39 additions & 2 deletions src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ use std::path::PathBuf;
use crate::ndjson::{self, SAFE_OUTPUT_FILENAME};
use crate::sanitize::sanitize as sanitize_text;
use crate::tools::{
CreatePrParams, CreatePrResult, CreateWorkItemParams, CreateWorkItemResult, MissingDataParams,
MissingDataResult, MissingToolParams, MissingToolResult, NoopParams, NoopResult, ToolResult,
CreatePrParams, CreatePrResult, CreateWorkItemParams, CreateWorkItemResult,
EditWikiPageParams, EditWikiPageResult, MissingDataParams, MissingDataResult,
MissingToolParams, MissingToolResult, NoopParams, NoopResult, ToolResult,
anyhow_to_mcp_error,
};

Expand Down Expand Up @@ -402,6 +403,42 @@ impl SafeOutputs {
repository, result.patch_file
))]))
}

#[tool(
name = "edit-wiki-page",
description = "Create or update an Azure DevOps wiki page with the provided markdown content. \
The page path (e.g. '/Overview/Architecture') and the wiki to write to are determined by the \
pipeline configuration. Use this to publish findings, summaries, documentation, or any other \
structured output that should be visible in the project wiki."
)]
async fn edit_wiki_page(
&self,
params: Parameters<EditWikiPageParams>,
) -> Result<CallToolResult, McpError> {
info!("Tool called: edit-wiki-page - '{}'", params.0.path);
debug!("Content length: {} chars", params.0.content.len());

// Sanitize untrusted agent-provided text fields (IS-01).
// Path: strip control characters to prevent injection into the NDJSON record.
// Content and comment: apply the full sanitization pipeline.
let mut sanitized = params.0;
sanitized.path = sanitized
.path
.chars()
.filter(|c| !c.is_control() || *c == '\t')
.collect();
sanitized.content = sanitize_text(&sanitized.content);
sanitized.comment = sanitized.comment.map(|c| sanitize_text(&c));

let result: EditWikiPageResult = sanitized.try_into()?;
let _ = self.write_safe_output_file(&result).await;

info!("Wiki page edit queued: '{}'", result.path);
Ok(CallToolResult::success(vec![Content::text(format!(
"Wiki page edit queued for '{}'. The page will be created or updated during safe output processing.",
result.path
))]))
}
}

// Implement the server handler
Expand Down
6 changes: 4 additions & 2 deletions src/tools/create_work_item.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
//! Create work item reporting schemas

use log::{debug, info};
use percent_encoding::utf8_percent_encode;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use super::PATH_SEGMENT;
use crate::tool_result;
use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate};
use crate::sanitize::{Sanitize, sanitize as sanitize_text};
Expand Down Expand Up @@ -257,8 +259,8 @@ impl Executor for CreateWorkItemResult {
let url = format!(
"{}/{}/_apis/wit/workitems/${}?api-version=7.0",
org_url.trim_end_matches('/'),
project,
config.work_item_type,
utf8_percent_encode(project, PATH_SEGMENT),
utf8_percent_encode(&config.work_item_type, PATH_SEGMENT),
);
debug!("API URL: {}", url);

Expand Down
Loading
Loading