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
120 changes: 32 additions & 88 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ The compiler transforms the input into valid Azure DevOps pipeline YAML based on
- **Standalone**: Uses `templates/base.yml`
- **1ES**: Uses `templates/1es-base.yml`

Explicit markings are embedded in these templates that the compiler is allowed to replace e.g. `{{ agency_params }}` denotes parameters which are passed to the agency command line tool. The compiler should not replace sections denoted by `${{ some content }}`. What follows is a mapping of markings to responsibilities (primarily for the standalone template).
Explicit markings are embedded in these templates that the compiler is allowed to replace e.g. `{{ copilot_params }}` denotes parameters which are passed to the copilot command line tool. The compiler should not replace sections denoted by `${{ some content }}`. What follows is a mapping of markings to responsibilities (primarily for the standalone template).

## {{ repositories }}
For each additional repository specified in the front matter append:
Expand Down Expand Up @@ -478,19 +478,16 @@ This distinction allows resources (like templates) to be available as pipeline r

Should be replaced with the human-readable name from the front matter (e.g., "Daily Code Review"). This is used for display purposes like stage names.

## {{ agency_params }}
## {{ copilot_params }}

Additional params provided to agency CLI. The compiler generates:
Additional params provided to copilot CLI. The compiler generates:
- `--model <model>` - AI model from `engine` front matter field (default: claude-opus-4.5)
- `--max-turns <n>` - Maximum agentic turns from `engine.max-turns` (omitted when not set)
- `--max-timeout <n>` - Workflow timeout in minutes from `engine.timeout-minutes` (omitted when not set)
- `--disable-builtin-mcps` - Disables all built-in MCPs initially
- `--no-ask-user` - Prevents interactive prompts
- `--allow-tool <tool>` - Explicitly allows specific tools (github, safeoutputs, write, shell commands like cat, date, echo, grep, head, ls, pwd, sort, tail, uniq, wc, yq)
- `--disable-mcp-server <name>` - Disables specific MCPs (all built-in MCPs are disabled by default and must be explicitly enabled via mcp-servers config)
- `--mcp <name>` - Enables MCPs specified in front matter
- `--disable-mcp-server <name>` - Disables specific Copilot CLI MCPs

Only built-in MCPs are passed via params. Custom MCPs (with command field) are handled separately.
All MCPs (both built-in and custom) are handled via the MCP firewall config, not via `--mcp` flags.

## {{ pool }}

Expand Down Expand Up @@ -559,7 +556,7 @@ Should be replaced with the appropriate working directory based on the effective
- `root`: `$(Build.SourcesDirectory)` - the checkout root directory
- `repo`: `$(Build.SourcesDirectory)/$(Build.Repository.Name)` - the repository's subfolder

This is used for the `workingDirectory` property of the agency copilot task.
This is used for the `workingDirectory` property of the copilot task.

## {{ source_path }}

Expand Down Expand Up @@ -733,16 +730,16 @@ Should be replaced with the agent context root for 1ES Agency jobs. This determi

## {{ mcp_configuration }}

Should be replaced with the MCP server configuration for 1ES templates. For each enabled built-in MCP, generates service connection references:
Should be replaced with the MCP server configuration for 1ES templates. For each `mcp-servers:` entry without a `command:` field, generates a service connection reference using the entry name:

```yaml
ado:
serviceConnection: mcp-ado-service-connection
kusto:
serviceConnection: mcp-kusto-service-connection
my-mcp:
serviceConnection: mcp-my-mcp-service-connection
other-mcp:
serviceConnection: mcp-other-mcp-service-connection
```

Custom MCP servers (with `command:` field) are not supported in 1ES target. Only built-in MCPs with corresponding service connections are supported.
Custom MCP servers (with `command:` field) are not supported in 1ES target. Only entries without a `command:` (which have a corresponding service connection) are supported.

## {{ global_options }}

Expand Down Expand Up @@ -1126,30 +1123,7 @@ cargo add <crate-name>

## MCP Configuration

The `mcp-servers:` field provides a unified way to configure both built-in and custom MCP (Model Context Protocol) servers. The compiler distinguishes between them by checking for the `command:` field—if present, it's a custom server; otherwise, it's a built-in.

### Built-in MCP Servers

Enable built-in servers with `true` or configure them with options:

```yaml
mcp-servers:
ado: true # enabled with all default functions
ado-ext: true # Extended ADO functionality
asa: true # Azure Stream Analytics MCP
bluebird: true # Bluebird MCP
calculator: true # Calculator MCP
es-chat: true
icm: # enabled with restricted functions
allowed:
- create_incident
- get_incident
kusto:
allowed:
- query
msft-learn: true
stack: true # Stack MCP
```
The `mcp-servers:` field configures custom MCP (Model Context Protocol) servers that the agent can use. Each entry must include a `command:` field specifying the executable to spawn.

### Custom MCP Servers

Expand All @@ -1167,28 +1141,16 @@ mcp-servers:

### Configuration Properties

**For built-in MCPs:**
- `true` - Enable with all default functions
- `allowed:` - Array of function names to restrict available tools
- `service-connection:` - (1ES target only) Override the service connection name used for this MCP. If not specified, defaults to `mcp-<name>-service-connection` (e.g., `mcp-ado-service-connection` for the `ado` MCP)

**For custom MCPs (requires `command:`):**
- `command:` - The executable to run (e.g., `"node"`, `"python"`, `"dotnet"`)
- `args:` - Array of command-line arguments passed to the command
- `allowed:` - Array of function names agents are permitted to call (required for security)
- `env:` - Optional environment variables for the MCP server process
- `service-connection:` - (1ES target only) Override the service connection name used for this MCP. If not specified, defaults to `mcp-<name>-service-connection`

### Example: Mixed Configuration
### Example: Multiple Custom MCP Servers

```yaml
mcp-servers:
# Built-in servers
ado: true
ado-ext: true
es-chat: true
icm:
allowed: [create_incident, get_incident]

# Custom Python MCP server
data-processor:
command: "python"
Expand All @@ -1214,7 +1176,6 @@ mcp-servers:
2. **Command Validation**: The compiler validates that commands are from a trusted set
3. **Argument Sanitization**: Arguments are validated to prevent injection attacks
4. **Environment Isolation**: MCP servers run in the same isolated sandbox as the pipeline
5. **Built-in Trust**: Built-in MCPs are pre-vetted; custom MCPs require explicit `allowed:` list

## Network Isolation (AWF)

Expand Down Expand Up @@ -1330,17 +1291,13 @@ When agents are configured with multiple MCPs (e.g., `ado`, `kusto`, `icm`), the

```
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ │ │ │ │ ado MCP
│ Agent │────▶│ MCP Firewall │────▶│ (agency mcp ado)│
│ (Agency) │ │ │ └─────────────────┘
│ │ │ │ │ custom MCP │
│ Agent │────▶│ MCP Firewall │────▶│ (node server.js)│
│ (Copilot) │ │ │ └─────────────────┘
│ │ │ - Policy check │ ┌─────────────────┐
└─────────────┘ │ - Tool routing │────▶│ icm MCP
│ - Audit logging │ │ (agency mcp icm)
└─────────────┘ │ - Tool routing │────▶│ custom MCP │
│ - Audit logging │ │ (python -m ...)
└──────────────────┘ └─────────────────┘
┌─────────────────┐
────▶│ custom MCP │
│ (node server.js)│
└─────────────────┘
```

### Configuration File Format
Expand All @@ -1350,23 +1307,11 @@ The firewall reads a JSON configuration file at runtime:
```json
{
"upstreams": {
"ado": {
"command": "agency",
"args": ["mcp", "ado"],
"env": {},
"allowed": ["*"]
},
"icm": {
"command": "agency",
"args": ["mcp", "icm"],
"env": {},
"allowed": ["create_incident", "get_incident"]
},
"kusto": {
"command": "agency",
"args": ["mcp", "kusto"],
"env": {},
"allowed": ["query"]
"data-processor": {
"command": "python",
"args": ["-m", "my_mcp_server"],
"env": { "DATA_DIR": "/data" },
"allowed": ["process_data", "query_database"]
},
"custom-tool": {
"command": "node",
Expand Down Expand Up @@ -1405,9 +1350,8 @@ The `allowed` field supports several patterns:

All tools exposed by the firewall are namespaced with their upstream name:

- `ado:create-work-item` - from the `ado` upstream
- `icm:create_incident` - from the `icm` upstream
- `kusto:query` - from the `kusto` upstream
- `data-processor:process_data` - from the `data-processor` upstream
- `custom-tool:get_status` - from the `custom-tool` upstream

This prevents tool name collisions and makes it clear which upstream handles each call.

Expand All @@ -1423,8 +1367,8 @@ ado-aw mcp-firewall --config /path/to/config.json
The firewall is automatically configured in generated pipelines:

1. **Config Generation**: The compiler generates `mcp-firewall-config.json` from the agent's `mcp-servers:` front matter
2. **MCP Registration**: The firewall is registered in the agency MCP config as `mcp-firewall`
3. **Runtime Launch**: When agency starts, it launches the firewall which spawns upstream MCPs
2. **MCP Registration**: The firewall is registered in the copilot MCP config as `mcp-firewall`
3. **Runtime Launch**: When copilot starts, it launches the firewall which spawns upstream MCPs

The firewall config is written to `$(Agent.TempDirectory)/staging/mcp-firewall-config.json` in its own pipeline step, making it easy to inspect and debug.

Expand All @@ -1433,9 +1377,9 @@ The firewall config is written to `$(Agent.TempDirectory)/staging/mcp-firewall-c
All tool call attempts are logged to the centralized log file at `$HOME/.ado-aw/logs/YYYY-MM-DD.log`:

```
[2026-01-29T10:15:32Z] [INFO] [firewall] ALLOWED icm:create_incident (args: {"title": "...", "severity": 3})
[2026-01-29T10:15:45Z] [INFO] [firewall] BLOCKED icm:delete_incident (not in allowlist)
[2026-01-29T10:16:01Z] [INFO] [firewall] ALLOWED kusto:query (args: {"cluster": "...", "query": "..."})
[2026-01-29T10:15:32Z] [INFO] [firewall] ALLOWED custom-tool:process_data (args: {"input": "..."})
[2026-01-29T10:15:45Z] [INFO] [firewall] BLOCKED custom-tool:delete_all (not in allowlist)
[2026-01-29T10:16:01Z] [INFO] [firewall] ALLOWED data-processor:query_database (args: {"query": "..."})
```

This provides a complete audit trail of agent actions for security review.
Expand Down
36 changes: 8 additions & 28 deletions src/compile/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

use anyhow::{Context, Result};

use super::types::{FrontMatter, McpConfig, Repository, TriggerConfig};
use super::types::{FrontMatter, Repository, TriggerConfig};
use crate::fuzzy_schedule;
use crate::mcp_metadata::McpMetadataFile;

/// Check if an MCP name is a built-in (launched via agency mcp)
/// Check if an MCP name is a built-in (known to the Copilot CLI via mcp-metadata.json)
pub fn is_builtin_mcp(name: &str) -> bool {
let metadata = McpMetadataFile::bundled();
metadata.get(name).map(|m| m.builtin).unwrap_or(false)
Expand Down Expand Up @@ -337,7 +337,7 @@ pub fn generate_copilot_params(front_matter: &FrontMatter) -> String {

for tool in allowed_tools {
if tool.contains('(') || tool.contains(')') || tool.contains(' ') {
// Use double quotes - the agency_params are embedded inside a single-quoted
// Use double quotes - the copilot_params are embedded inside a single-quoted
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Is this function used? copilot CLI does not have an mcp subcommand.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, generate_copilot_params is used — it's called from both standalone.rs and onees.rs. The specific thing it referenced that doesn't exist was --mcp <name> (the loop on lines 330–344 that re-enabled named MCPs). Removed that block in ad38bc9, along with the test_copilot_params_builtin_mcp_added_with_mcp_flag test and the --mcp <name> bullet from the AGENTS.md {{ copilot_params }} section.

// bash string in the AWF command, so single quotes would break quoting.
params.push(format!("--allow-tool \"{}\"", tool));
} else {
Expand All @@ -349,22 +349,6 @@ pub fn generate_copilot_params(front_matter: &FrontMatter) -> String {
params.push(format!("--disable-mcp-server {}", mcp));
}

for (name, config) in &front_matter.mcp_servers {
let is_custom = matches!(config, McpConfig::WithOptions(opts) if opts.command.is_some());
if is_custom {
continue;
}

let is_enabled = match config {
McpConfig::Enabled(enabled) => *enabled,
McpConfig::WithOptions(_) => true,
};

if is_enabled {
params.push(format!("--mcp {}", name));
}
}

params.join(" ")
}

Expand Down Expand Up @@ -612,11 +596,7 @@ fn normalize_relative_path(path: &std::path::Path) -> Option<String> {
/// traversal reaches the filesystem root without finding one.
fn find_git_root(path: &std::path::Path) -> Option<std::path::PathBuf> {
// Start from the file's parent directory (or the path itself if it is a dir).
let start: &std::path::Path = if path.is_dir() {
path
} else {
path.parent()?
};
let start: &std::path::Path = if path.is_dir() { path } else { path.parent()? };

let mut current = start.to_path_buf();
loop {
Expand Down Expand Up @@ -893,7 +873,7 @@ mod tests {
}

#[test]
fn test_copilot_params_custom_mcp_not_added_with_mcp_flag() {
fn test_copilot_params_custom_mcp_no_mcp_flag() {
let mut fm = minimal_front_matter();
fm.mcp_servers.insert(
"my-tool".to_string(),
Expand All @@ -903,17 +883,17 @@ mod tests {
}),
);
let params = generate_copilot_params(&fm);
// Custom MCPs (with command) should NOT appear as --mcp flags
assert!(!params.contains("--mcp my-tool"));
}

#[test]
fn test_copilot_params_builtin_mcp_added_with_mcp_flag() {
fn test_copilot_params_builtin_mcp_no_mcp_flag() {
let mut fm = minimal_front_matter();
fm.mcp_servers
.insert("ado".to_string(), McpConfig::Enabled(true));
let params = generate_copilot_params(&fm);
assert!(params.contains("--mcp ado"));
// Copilot CLI has no built-in MCPs — all MCPs are handled via the MCP firewall
assert!(!params.contains("--mcp ado"));
}

#[test]
Expand Down
4 changes: 2 additions & 2 deletions src/compile/onees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ impl Compiler for OneESCompiler {
let repositories = generate_repositories(&front_matter.repositories);
let checkout_steps = generate_checkout_steps(&front_matter.checkout);
let checkout_self = generate_checkout_self();
let agency_params = generate_copilot_params(front_matter);
let copilot_params = generate_copilot_params(front_matter);

let effective_workspace = compute_effective_workspace(
&front_matter.workspace,
Expand Down Expand Up @@ -169,7 +169,7 @@ displayName: "Finalize""#,
("{{ pipeline_path }}", &pipeline_path),
("{{ working_directory }}", &working_directory),
("{{ workspace }}", &working_directory),
("{{ agency_params }}", &agency_params),
("{{ copilot_params }}", &copilot_params),
("{{ acquire_ado_token }}", &acquire_read_token),
("{{ copilot_ado_env }}", &copilot_ado_env),
("{{ acquire_write_token }}", &acquire_write_token),
Expand Down
Loading
Loading