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
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", "edit-wiki-page"];
const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &["create-pull-request", "create-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
57 changes: 55 additions & 2 deletions src/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ use std::path::Path;

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

// Re-export memory types for use by main.rs
Expand Down Expand Up @@ -193,6 +193,17 @@ pub async fn execute_safe_output(
);
output.execute_sanitized(ctx).await?
}
"create-wiki-page" => {
debug!("Parsing create-wiki-page payload");
let mut output: CreateWikiPageResult = serde_json::from_value(entry.clone())
.map_err(|e| anyhow::anyhow!("Failed to parse create-wiki-page: {}", e))?;
debug!(
"create-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 @@ -425,4 +436,46 @@ mod tests {
.contains("AZURE_DEVOPS_ORG_URL")
);
}

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

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

#[tokio::test]
async fn test_execute_create_wiki_page_missing_context() {
let entry = serde_json::json!({
"name": "create-wiki-page",
"path": "/NewPage",
"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")
);
}
}
40 changes: 39 additions & 1 deletion src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ 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,
CreatePrParams, CreatePrResult, CreateWikiPageParams, CreateWikiPageResult,
CreateWorkItemParams, CreateWorkItemResult,
EditWikiPageParams, EditWikiPageResult, MissingDataParams, MissingDataResult,
MissingToolParams, MissingToolResult, NoopParams, NoopResult, ToolResult,
anyhow_to_mcp_error,
Expand Down Expand Up @@ -439,6 +440,43 @@ structured output that should be visible in the project wiki."
result.path
))]))
}

#[tool(
name = "create-wiki-page",
description = "Create a new Azure DevOps wiki page with the provided markdown content. \
The page path (e.g. '/Overview/NewPage') and the wiki to write to are determined by the \
pipeline configuration. The page must not already exist — use edit-wiki-page to update \
existing pages. Use this to publish findings, summaries, documentation, or any other \
structured output that should be visible in the project wiki."
)]
async fn create_wiki_page(
&self,
params: Parameters<CreateWikiPageParams>,
) -> Result<CallToolResult, McpError> {
info!("Tool called: create-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: CreateWikiPageResult = sanitized.try_into()?;
let _ = self.write_safe_output_file(&result).await;

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

// Implement the server handler
Expand Down
Loading
Loading