From 5b0b88040b73a500e40d9857d43461a6de32059a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:34:28 +0000 Subject: [PATCH 1/3] chore(mcp): remove unused HTTP transport Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- AGENTS.md | 4 +- Cargo.toml | 3 - README.md | 3 - docs/cli.md | 5 - docs/local-development.md | 229 --------- docs/mcpg.md | 5 +- .../src/content/docs/reference/mcp-author.mdx | 3 +- site/src/content/docs/reference/mcpg.mdx | 157 +++---- site/src/content/docs/setup/cli.mdx | 16 +- src/main.rs | 46 +- src/mcp.rs | 123 ----- tests/mcp_http_tests.rs | 441 ------------------ tests/test_mcpg_local.sh | 278 ----------- 13 files changed, 74 insertions(+), 1239 deletions(-) delete mode 100644 docs/local-development.md delete mode 100644 tests/mcp_http_tests.rs delete mode 100755 tests/test_mcpg_local.sh diff --git a/AGENTS.md b/AGENTS.md index 7ece836a..3aa8e338 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -352,7 +352,7 @@ index to jump to the right page. - [`docs/ir.md`](docs/ir.md) — typed Azure DevOps pipeline IR (`Pipeline`, jobs/stages/steps, output refs, graph pass, lowering, target builders, and the public JSON summary consumed by agent-facing tooling). - [`docs/cli.md`](docs/cli.md) — `ado-aw` CLI commands (`init`, `compile`, - `check`, `mcp`, `mcp-http`, `execute`, `secrets`, `enable`, `disable`, + `check`, `mcp`, `execute`, `secrets`, `enable`, `disable`, `remove`, `list`, `status`, `run`, `audit`, `mcp-author`, `trace`, `inspect`, `graph`, `whatif`, `lint`, `catalog`; `configure` is a deprecated hidden alias; `export-gate-schema` and `export-fact-catalog` are hidden build-time tools). @@ -391,8 +391,6 @@ index to jump to the right page. type codegen, the A2 design decision, the bundle env contract modelled in `src/compile/ado_bundle.rs`, and the `trigger-e2e/` gate-spec drift guard (kept in sync via `export-fact-catalog`). -- [`docs/local-development.md`](docs/local-development.md) — local development - setup notes. ## Development Guidelines diff --git a/Cargo.toml b/Cargo.toml index 56679336..f35b91ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,6 @@ schemars = { version = "1.2", features = ["derive"] } rmcp = { version = "1.4.0", features = [ "server", "transport-io", - "transport-streamable-http-server", ] } percent-encoding = "2.3" reqwest = { version = "0.12", features = ["json"] } @@ -28,8 +27,6 @@ env_logger = "0.11" regex-lite = "0.1" inquire = "0.9.2" # Used by configure command (Password prompts) url = "2.5.8" -axum = { version = "0.8.8", features = ["tokio"] } -subtle = "2.6.1" rand = "0.10.1" base64 = "0.22.1" glob-match = "0.2.1" diff --git a/README.md b/README.md index b00bfe42..0030c409 100644 --- a/README.md +++ b/README.md @@ -634,7 +634,6 @@ Commands: check Verify a compiled pipeline matches its source mcp Run as an MCP server (safe outputs) mcp-author Run the author-facing MCP server over stdio (IDE/Copilot Chat integration) - mcp-http Run as an HTTP MCP server (for MCPG integration) execute Execute safe outputs (Stage 3) secrets Manage pipeline-variable secrets on matched ADO definitions (set/list/delete) enable Register ADO build definitions for compiled pipelines and ensure they are enabled @@ -765,8 +764,6 @@ index to jump to the right page. - [`docs/extending.md`](docs/extending.md) — adding new CLI commands, compile targets, front-matter fields, typed IR extensions, safe-output tools, first-class tools, and runtimes. -- [`docs/local-development.md`](docs/local-development.md) — local development - setup notes. --- diff --git a/docs/cli.md b/docs/cli.md index cba928de..23775963 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -26,11 +26,6 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg - `mcp ` - Run SafeOutputs as a stdio MCP server. **This is what compiled pipelines use**: MCPG spawns it as a hardened, network-isolated sibling container entrypoint (see [`docs/mcpg.md`](mcpg.md)). - `--enabled-tools ` - Restrict available tools to those named (repeatable) - `mcp-author` - Run the author-facing stdio MCP server for IDE/Copilot Chat integrations. See [`mcp-author.md`](mcp-author.md) for the full tool surface and trust model. -- `mcp-http ` - Run SafeOutputs as an HTTP MCP server (for direct/local MCPG integration or manual testing). **Optional and not compiler-emitted** — compiled pipelines no longer start this server; see [`docs/local-development.md`](local-development.md). - - `--bind-address ` - Listener IP address (default: `127.0.0.1`) - - `--port ` - Port to listen on (default: 8100) - - `--api-key ` - API key for authentication (auto-generated if not provided) - - `--enabled-tools ` - Restrict available tools to those named (repeatable) - `execute` - Execute safe outputs from Stage 1 (Stage 3 of pipeline) - `--source, -s ` - Path to source markdown file - `--safe-output-dir ` - Directory containing safe output NDJSON (default: current directory) diff --git a/docs/local-development.md b/docs/local-development.md deleted file mode 100644 index bc5756c1..00000000 --- a/docs/local-development.md +++ /dev/null @@ -1,229 +0,0 @@ - - -# Local Development Guide - -This guide explains how to run an agentic pipeline locally for development and -testing using the optional `ado-aw mcp-http` command, which remains supported -for direct/local use. **This is not what compiled pipelines run.** Since the -containerized SafeOutputs architecture landed, compiled pipelines spawn -SafeOutputs as a hardened stdio child container through MCPG (`ado-aw mcp`, -`--network none`, non-root, read-only rootfs) — there is no host-side HTTP -server, bridge-gateway resolution, or `host.docker.internal` mapping in CI. -See [`docs/mcpg.md`](mcpg.md) for that shape. The manual `mcp-http` workflow -below is a simpler, still-supported way to exercise an agent's prompt + MCP -wiring by hand without a full pipeline replica. - -## Prerequisites - -- **ado-aw** built from source (`cargo build`) -- **Copilot CLI** on your PATH (`copilot --version`) -- **Docker** (optional, required for MCPG / custom MCP servers) -- An Azure DevOps PAT if your agent uses ADO APIs - -## Overview - -A pipeline execution has three stages: - -1. **SafeOutputs MCP server** — receives tool calls from the agent and writes - them as NDJSON records. Locally this guide uses `ado-aw mcp-http`; compiled - pipelines use `ado-aw mcp` as a stdio child spawned by MCPG instead. -2. **Agent execution** — Copilot CLI runs with a prompt and MCP config, - interacting with SafeOutputs (and optionally other MCPs via MCPG) -3. **Safe output execution** — processes the NDJSON records and makes real ADO - API calls (create PRs, work items, etc.) - -## Step-by-step - -### 1. Create a working directory - -```bash -export WORK_DIR=$(mktemp -d) -echo "Working directory: $WORK_DIR" -``` - -### 2. Start the SafeOutputs HTTP server (local-only; optional `mcp-http`) - -This step is specific to the local manual workflow. Resolve the Docker bridge -gateway address so a locally-run MCPG container (started outside AWF) can -reach the host-side server: - -```bash -# Resolve the Docker bridge gateway (same address MCPG uses to reach the host) -export SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge | jq -er '.[0].IPAM.Config[0].Gateway') - -# Pick a port and generate an API key -export SO_PORT=8100 -export SO_API_KEY=$(openssl rand -hex 32) - -# Start in the background, bound only to the bridge gateway address -cargo run -- mcp-http \ - --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ - --port "$SO_PORT" \ - --api-key "$SO_API_KEY" \ - "$WORK_DIR" \ - "$(pwd)" \ - > "$WORK_DIR/safeoutputs.log" 2>&1 & -export SO_PID=$! -echo "SafeOutputs PID: $SO_PID" - -# Wait for health check -until curl -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SO_PORT/health" > /dev/null 2>&1; do - sleep 1 -done -echo "SafeOutputs ready" -``` - -### 3. (Optional) Start MCPG for custom MCP servers - -Skip this step if your agent only uses SafeOutputs (no `mcp-servers:` or -`tools: azure-devops:` in front matter). - -```bash -export MCPG_PORT=8080 -export MCPG_API_KEY=$(openssl rand -hex 32) - -# Generate MCPG config for this manual local flow. Note: compiled pipelines -# wire "safeoutputs" as a hardened stdio child container (`type: "stdio"`, -# container: the pinned AWF `agent` image, entrypoint: ado-aw mcp ...; see -# docs/mcpg.md), not the HTTP backend shown below -- the HTTP shape here is -# specific to this local/manual workflow, which runs SafeOutputs as a plain -# host process for simplicity. -cat > "$WORK_DIR/mcpg-config.json" < "$WORK_DIR/gateway-output.json" 2>"$WORK_DIR/mcpg-stderr.log" & -export MCPG_PID=$! - -# Wait for MCPG health check -until curl -sf "http://127.0.0.1:$MCPG_PORT/health" > /dev/null 2>&1; do - sleep 1 -done -echo "MCPG ready" -``` - -### 4. Generate the MCP client config for Copilot - -**Without MCPG** (SafeOutputs only): - -```bash -cat > "$WORK_DIR/mcp-config.json" <=2' "$AGENT_FILE" > "$WORK_DIR/agent-prompt.md" -``` - -### 6. Run the Copilot CLI - -```bash -copilot \ - --prompt "@$WORK_DIR/agent-prompt.md" \ - --additional-mcp-config "@$WORK_DIR/mcp-config.json" \ - --model claude-opus-4.7 \ - --no-ask-user \ - --disable-builtin-mcps \ - --allow-all-tools -``` - -Adjust flags based on your agent's front matter (model, allowed tools, etc.). - -### 7. Execute safe outputs - -```bash -cargo run -- execute \ - --source "$AGENT_FILE" \ - --safe-output-dir "$WORK_DIR" \ - --dry-run # Remove --dry-run to make real ADO API calls -``` - -### 8. Cleanup - -```bash -# Stop SafeOutputs -kill "$SO_PID" 2>/dev/null - -# Stop MCPG (if started) -docker stop awmg-mcpg 2>/dev/null - -echo "Done. Output files in: $WORK_DIR" -``` - -## Tips - -- Use `--dry-run` on the execute step to validate safe outputs without making - real ADO API calls -- Set `AZURE_DEVOPS_EXT_PAT` for agents that need ADO API access -- Check `$WORK_DIR/safeoutputs.log` and `$WORK_DIR/mcpg-stderr.log` for - debugging -- The compiled pipeline YAML shows the exact flags and config used in CI — use - `ado-aw compile your-agent.md` and inspect the output for reference diff --git a/docs/mcpg.md b/docs/mcpg.md index 15c35c12..e2f26c9f 100644 --- a/docs/mcpg.md +++ b/docs/mcpg.md @@ -63,8 +63,7 @@ no bridge-gateway resolution, and no `host.docker.internal` mapping. 1. **MCPG container** starts on Docker's bridge network as the named, trusted container `awmg-mcpg`, published to the host at `127.0.0.1:8080`, with the Docker socket mounted so it can spawn stdio - children — see [`docs/mcp.md`](mcp.md) and - [`docs/local-development.md`](local-development.md). + children — see [`docs/mcp.md`](mcp.md). 2. **MCPG config** (generated by the compiler) defines: - SafeOutputs as a hardened stdio backend (`type: "stdio"`, `container:` the pinned AWF `agent` image, `entrypoint: /usr/local/bin/ado-aw`) @@ -166,4 +165,4 @@ The MCPG is automatically configured in generated standalone pipelines: 3. **Agent Execution**: AWF runs the Agent rootlessly with `--network-isolation --topology-attach awmg-mcpg`, attaching the MCPG container to `awf-net`; copilot connects to MCPG at `awmg-mcpg:8080` over HTTP, and reaches SafeOutputs tools transparently through MCPG's stdio routing. 4. **Cleanup**: MCPG and any stdio children it spawned (including SafeOutputs) are stopped after the agent completes (condition: always). -The MCPG config is written to `$(Agent.TempDirectory)/staging/mcpg-config.json` in its own pipeline step, making it easy to inspect and debug. The optional `ado-aw mcp-http` CLI command remains available for direct/local use (see [`docs/cli.md`](cli.md) and [`docs/local-development.md`](local-development.md)), but compiled pipelines never invoke it — they always use the `ado-aw mcp` stdio subcommand through MCPG. +The MCPG config is written to `$(Agent.TempDirectory)/staging/mcpg-config.json` in its own pipeline step, making it easy to inspect and debug. SafeOutputs is always run with the `ado-aw mcp` stdio subcommand through MCPG. diff --git a/site/src/content/docs/reference/mcp-author.mdx b/site/src/content/docs/reference/mcp-author.mdx index 1f0ffc9a..05a874bc 100644 --- a/site/src/content/docs/reference/mcp-author.mdx +++ b/site/src/content/docs/reference/mcp-author.mdx @@ -8,13 +8,12 @@ import { Aside, Tabs, TabItem } from '@astrojs/starlight/components'; `ado-aw mcp-author` runs a **local, read-only MCP server over stdio** for IDE and Copilot Chat integrations. It exposes the full set of IR analysis commands — inspect, graph traversal, lint, what-if, trace, and audit — as MCP tools so your IDE agent can reason about ado-aw workflows without you running CLI commands by hand. ## When to use it diff --git a/site/src/content/docs/reference/mcpg.mdx b/site/src/content/docs/reference/mcpg.mdx index 5227ec2e..115dde53 100644 --- a/site/src/content/docs/reference/mcpg.mdx +++ b/site/src/content/docs/reference/mcpg.mdx @@ -7,131 +7,110 @@ import { Steps } from '@astrojs/starlight/components'; ## MCP Gateway (MCPG) -The MCP Gateway ([gh-aw-mcpg](https://github.com/github/gh-aw-mcpg)) is the upstream MCP routing layer that connects agents to their configured MCP servers. It replaces the previous custom MCP firewall with the standard gh-aw gateway implementation. +The MCP Gateway ([gh-aw-mcpg](https://github.com/github/gh-aw-mcpg)) is the +upstream MCP routing layer that connects agents to their configured MCP +servers. ## Architecture ```text - Host -┌─────────────────────────────────────────────────┐ -│ │ -│ ┌──────────────┐ ┌──────────────────────┐ │ -│ │ SafeOutputs │ │ MCPG Gateway │ │ -│ │ HTTP Server │◀────│ (Docker, --network │ │ -│ │ (ado-aw │ │ host, port 80) │ │ -│ │ mcp-http) │ │ │ │ -│ │ port 8100 │ │ Routes tool calls │ │ -│ └──────────────┘ │ to upstreams │ │ -│ └──────────┬───────────┘ │ -│ │ │ -│ ┌─────────────────┐ │ │ -│ │ Custom MCP │◀────┘ │ -│ │ (stdio server) │ │ -│ └─────────────────┘ │ -└─────────────────────────────────────────────────┘ - │ - host.docker.internal:80 - │ -┌─────────────────────────────────────────────────┐ -│ AWF Container │ -│ │ -│ ┌──────────┐ │ -│ │ Copilot │──── HTTP ──── MCPG (via host) │ -│ │ Agent │ │ -│ └──────────┘ │ -└─────────────────────────────────────────────────┘ +Host +┌───────────────────────────────────────────────┐ +│ MCPG Gateway (awmg-mcpg, Docker bridge) │ +│ ├─ SafeOutputs stdio child (`ado-aw mcp`) │ +│ └─ Configured custom MCP backends │ +└────────────────────────┬──────────────────────┘ + │ awmg-mcpg:8080 +┌────────────────────────┴──────────────────────┐ +│ AWF container (awf-net) │ +│ Copilot agent ── HTTP ── MCPG │ +└───────────────────────────────────────────────┘ ``` +SafeOutputs has no network listener. MCPG spawns it as a hardened stdio child +container with no network access and routes the agent's MCP calls to it. + ## How It Works -1. **Start SafeOutputs HTTP server** - - The SafeOutputs MCP server starts on the host machine (port 8100) via `ado-aw mcp-http`, exposing the safe-output tool set over HTTP. - -2. **Start MCPG container** +1. **Generate MCPG configuration** - The MCPG Gateway starts as a Docker container on the host network (`docker run --network host`), binding to port 80 for agent connections. + The compiler defines SafeOutputs as a stdio backend and adds any custom + stdio or HTTP backends configured in the workflow. -3. **Configure MCPG routing** +2. **Start MCPG** - The compiler-generated `mcpg-config.json` defines: - - SafeOutputs as an HTTP backend (`type: "http"`, URL points to localhost:8100) - - Custom MCPs as stdio servers (`type: "stdio"`, spawned by MCPG) - - Gateway settings (port 80, API key, payload directory) + MCPG starts as the named `awmg-mcpg` container on Docker's bridge network. + It receives the generated configuration on stdin and can use the mounted + Docker socket to create stdio child containers. -4. **Agent connects to MCPG** +3. **Attach the agent** - The Copilot agent inside the AWF container connects to MCPG via `http://host.docker.internal:80/mcp`. + AWF runs the agent with strict network isolation and attaches the trusted + MCPG container to `awf-net`. The agent reaches MCPG at + `http://awmg-mcpg:8080/mcp`; it has no route to the host. -5. **Route tool calls** +4. **Route tools** - MCPG receives tool calls from the agent and routes them to the appropriate upstream server (SafeOutputs HTTP server or custom stdio MCPs). + MCPG routes SafeOutputs calls to the `ado-aw mcp` stdio child and custom + tool calls to their configured backends. -6. **Clean up** +5. **Clean up** - After the agent completes, both MCPG and the SafeOutputs HTTP server are stopped (condition: always). + The pipeline stops MCPG and its stdio children after the agent completes. -## MCPG Configuration Format +## SafeOutputs Configuration -The compiler generates MCPG configuration JSON from the `mcp-servers:` front matter: +The generated MCPG configuration models SafeOutputs as a containerized stdio +server: ```json { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://localhost:8100/mcp", - "headers": { - "Authorization": "Bearer " - } - }, - "custom-tool": { "type": "stdio", - "container": "node:20-slim", - "entrypoint": "node", - "entrypointArgs": ["server.js"], - "tools": ["process_data", "get_status"] + "container": "", + "entrypoint": "/usr/local/bin/ado-aw", + "entrypointArgs": [ + "mcp", + "/safeoutputs", + "/workspace" + ], + "mounts": [ + "/tmp/awf-tools/ado-aw:/usr/local/bin/ado-aw:ro", + "/workspace:/workspace:rw", + "/tmp/awf-tools/staging:/safeoutputs:rw" + ], + "args": [ + "--network", "none", + "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", + "--read-only" + ], + "env": { + "HOME": "/tmp" + } } }, "gateway": { - "port": 80, - "domain": "host.docker.internal", - "apiKey": "", - "payloadDir": "/tmp/gh-aw/mcp-payloads" + "port": 8080, + "domain": "awmg-mcpg", + "apiKey": "${MCP_GATEWAY_API_KEY}" } } ``` -Runtime placeholders (`${SAFE_OUTPUTS_PORT}`, `${SAFE_OUTPUTS_API_KEY}`, `${MCP_GATEWAY_API_KEY}`) are substituted by the pipeline before passing the config to MCPG. +The exact paths, mounts, user IDs, enabled tool arguments, image, and +hardening options are generated from the workflow and runner environment. +See the detailed [MCPG reference in the repository](https://github.com/githubnext/ado-aw/blob/main/docs/mcpg.md). ## Pipeline Integration -The MCPG is automatically configured in generated standalone pipelines: - - - -1. **Generate MCPG configuration** - - The compiler generates `mcpg-config.json` from the agent's `mcp-servers:` front matter and writes it to `$(Agent.TempDirectory)/staging/mcpg-config.json` for inspection. - -2. **Start SafeOutputs HTTP server** - - The pipeline starts `ado-aw mcp-http` as a background process on the host, listening on port 8100. - -3. **Start MCPG Gateway** - - The MCPG Docker container starts on the host network with the generated config passed via stdin. - -4. **Execute agent** - - AWF runs the agent with `--enable-host-access`, allowing the Copilot CLI inside AWF to connect to MCPG via HTTP at `http://host.docker.internal:80/mcp`. - -5. **Clean up services** - - Both MCPG and the SafeOutputs HTTP server are stopped after the agent completes (condition: always). - - +The compiler writes the configuration to +`$(Agent.TempDirectory)/staging/mcpg-config.json`, starts MCPG before the +agent, and tears it down afterward. The SafeOutputs child runs as non-root, +with a read-only root filesystem, `--network none`, and only the required +worktree and staging mounts. diff --git a/site/src/content/docs/setup/cli.mdx b/site/src/content/docs/setup/cli.mdx index ac1ad501..c2bad41f 100644 --- a/site/src/content/docs/setup/cli.mdx +++ b/site/src/content/docs/setup/cli.mdx @@ -178,7 +178,7 @@ ado-aw mcp-author No flags; add the server to your IDE's MCP config pointing to the `ado-aw mcp-author` command. See the [Author MCP Server reference](/ado-aw/reference/mcp-author/) for tool parameters, IDE configuration snippets (VS Code, Claude Desktop, Cursor), and trust model. :::note[Not the SafeOutputs server] -`mcp-author` is a local developer tool. The `ado-aw mcp` and `ado-aw mcp-http` commands are the SafeOutputs servers used by compiled pipelines at Stage 1. They are documented in the [Internal / pipeline runtime commands](#internal--pipeline-runtime-commands) section below. +`mcp-author` is a local developer tool. The `ado-aw mcp` command is the SafeOutputs server used by compiled pipelines at Stage 1. It is documented in the [Internal / pipeline runtime commands](#internal--pipeline-runtime-commands) section below. ::: ## Pipeline lifecycle commands @@ -375,20 +375,6 @@ Options: - `--enabled-tools ` -- only expose these safe output tools; repeat to allow more than one. If omitted, all tools are exposed -### `mcp-http ` - -Run SafeOutputs as an HTTP MCP server (for MCPG integration). The `` guards against directory traversal attacks. - -```bash -ado-aw mcp-http [options] -``` - -Options: - -- `--port ` -- port to listen on (default: `8100`) -- `--api-key ` -- API key for authentication (if not provided, one is generated) -- `--enabled-tools ` -- only expose these safe output tools; repeat to allow more than one. If omitted, all tools are exposed - ### `execute` Execute safe outputs as the Stage 3 executor. diff --git a/src/main.rs b/src/main.rs index 6c41e657..13d16fd8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -275,25 +275,6 @@ enum Commands { #[arg(long = "exclude")] exclude: Vec, }, - /// Run SafeOutputs MCP server over HTTP (for MCPG integration) - McpHttp { - /// IP address to bind the HTTP listener to - #[arg(long, default_value = "127.0.0.1")] - bind_address: std::net::IpAddr, - /// Port to listen on - #[arg(long, default_value = "8100")] - port: u16, - /// API key for authentication (if not provided, one is generated) - #[arg(long)] - api_key: Option, - /// Directory for safe output files - output_directory: String, - /// Guard against directory traversal attacks - bounding_directory: String, - /// Only expose these safe output tools (can be repeated). If omitted, all tools are exposed. - #[arg(long = "enabled-tools")] - enabled_tools: Vec, - }, /// Initialize a repository for AI-first agentic workflow authoring Init { /// Target directory (defaults to current directory) @@ -998,7 +979,6 @@ async fn main() -> Result<()> { Some(Commands::Mcp { .. }) => "mcp", Some(Commands::McpAuthor { .. }) => "mcp-author", Some(Commands::Execute { .. }) => "execute", - Some(Commands::McpHttp { .. }) => "mcp-http", Some(Commands::Init { .. }) => "init", Some(Commands::Configure { .. }) => "configure", Some(Commands::Secrets { .. }) => "secrets", @@ -1034,7 +1014,7 @@ async fn main() -> Result<()> { }; // Check for a newer release on GitHub and nudge the user to update. - // Skipped for pipeline-internal commands (execute, mcp, mcp-http) that + // Skipped for pipeline-internal commands (execute and mcp) that // run inside network-isolated sandboxes and are not invoked by humans. // Also skipped in CI environments to avoid unnecessary outbound calls. let is_pipeline_internal = matches!( @@ -1042,7 +1022,6 @@ async fn main() -> Result<()> { Commands::Execute { .. } | Commands::Mcp { .. } | Commands::McpAuthor { .. } - | Commands::McpHttp { .. } ); let update_handle = if !is_pipeline_internal && std::env::var_os("CI").is_none() { Some(tokio::spawn(update_check::check_for_update())) @@ -1112,29 +1091,6 @@ async fn main() -> Result<()> { ) .await?; } - Commands::McpHttp { - bind_address, - port, - api_key, - output_directory, - bounding_directory, - enabled_tools, - } => { - let filter = if enabled_tools.is_empty() { - None - } else { - Some(enabled_tools) - }; - mcp::run_http( - &output_directory, - &bounding_directory, - bind_address, - port, - api_key.as_deref(), - filter.as_deref(), - ) - .await?; - } Commands::Init { path, force, diff --git a/src/mcp.rs b/src/mcp.rs index 221c76ac..c691e0d4 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1529,129 +1529,6 @@ pub async fn run( Ok(()) } -/// Run SafeOutputs MCP server over HTTP using the Streamable HTTP protocol. -/// -/// This is used for MCPG integration: the gateway connects to this server as an -/// HTTP backend and proxies tool calls from the agent. -pub async fn run_http( - output_directory: &str, - bounding_directory: &str, - bind_address: std::net::IpAddr, - port: u16, - api_key: Option<&str>, - enabled_tools: Option<&[String]>, -) -> Result<()> { - use axum::Router; - use rmcp::transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }; - use std::sync::Arc; - - let bounding = bounding_directory.to_string(); - let output = output_directory.to_string(); - - // Generate or use provided API key. - // In production the pipeline always passes --api-key with a cryptographically - // random value; this fallback covers dev/test invocations. - let api_key = match api_key { - Some(k) => k.to_string(), - None => { - let mut buf = [0u8; 32]; - std::fs::File::open("/dev/urandom") - .and_then(|mut f| { - use std::io::Read; - f.read_exact(&mut buf) - }) - .context( - "Cannot generate secure API key: /dev/urandom unavailable. \ - Pass --api-key explicitly.", - )?; - buf.iter().map(|b| format!("{:02x}", b)).collect() - } - }; - - info!( - "Starting SafeOutputs HTTP server on {}:{}", - bind_address, port - ); - - let mut config = StreamableHttpServerConfig::default(); - config.allowed_hosts.push(bind_address.to_string()); - - let session_manager = Arc::new(LocalSessionManager::default()); - - // Pre-initialize SafeOutputs once and share via clone. - // The factory closure runs on a Tokio worker thread, so we cannot - // use block_on() inside it — that would panic with "Cannot start - // a runtime from within a runtime". - let safe_outputs_template = SafeOutputs::new(&bounding, &output, enabled_tools).await?; - let mcp_service = StreamableHttpService::new( - move || Ok(safe_outputs_template.clone()), - session_manager, - config, - ); - - // Wrap with API key auth middleware (constant-time comparison to - // prevent timing side-channels from a compromised AWF container). - let expected_key = api_key.clone(); - let app = Router::new() - .route("/health", axum::routing::get(|| async { "ok" })) - .route( - "/mcp", - axum::routing::post(axum::routing::any_service(mcp_service.clone())) - .get(axum::routing::any_service(mcp_service.clone())) - .delete(axum::routing::any_service(mcp_service)), - ) - .layer(axum::middleware::from_fn( - move |req: axum::extract::Request, next: axum::middleware::Next| { - let expected = expected_key.clone(); - async move { - // Skip auth for health endpoint - if req.uri().path() == "/health" { - return next.run(req).await; - } - - // Constant-time comparison to prevent timing side-channels. - // Length check is non-constant-time but leaking length doesn't - // help brute-force a high-entropy token. - if let Some(auth) = req.headers().get("authorization") - && let Ok(auth_str) = auth.to_str() - { - let expected_header = format!("Bearer {}", expected); - use subtle::ConstantTimeEq; - let expected_bytes = expected_header.as_bytes(); - let provided_bytes = auth_str.as_bytes(); - if expected_bytes.len() == provided_bytes.len() - && expected_bytes.ct_eq(provided_bytes).into() - { - return next.run(req).await; - } - } - - use axum::response::IntoResponse; - (axum::http::StatusCode::UNAUTHORIZED, "Unauthorized").into_response() - } - }, - )); - - let addr = std::net::SocketAddr::new(bind_address, port); - let listener = tokio::net::TcpListener::bind(addr).await?; - info!("SafeOutputs HTTP server listening on {}", addr); - - // Print port for pipeline capture (key is already known by the caller) - println!("SAFE_OUTPUTS_PORT={}", port); - log::debug!("SafeOutputs API key configured (not printed for security)"); - - axum::serve(listener, app) - .with_graceful_shutdown(async { - tokio::signal::ctrl_c().await.ok(); - info!("SafeOutputs HTTP server shutting down"); - }) - .await?; - - Ok(()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/tests/mcp_http_tests.rs b/tests/mcp_http_tests.rs deleted file mode 100644 index dd1d9026..00000000 --- a/tests/mcp_http_tests.rs +++ /dev/null @@ -1,441 +0,0 @@ -use std::io::BufRead; -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -// Integration tests for the SafeOutputs HTTP server (`mcp-http` subcommand). -// -// These tests validate the optional HTTP transport directly. Compiled -// pipelines use the stdio transport through an MCPG-spawned container. - -/// Guard that kills the child process on drop (even on panic). -struct ServerGuard { - child: Child, - port: u16, - api_key: String, - _temp_dir: tempfile::TempDir, - #[allow(dead_code)] - stderr_thread: Option>, -} - -impl Drop for ServerGuard { - fn drop(&mut self) { - self.child.kill().ok(); - self.child.wait().ok(); - } -} - -/// Helper: find a free TCP port on the requested loopback address. -fn free_port(bind_address: &str) -> u16 { - let listener = std::net::TcpListener::bind((bind_address, 0)).unwrap(); - listener.local_addr().unwrap().port() -} - -/// Start SafeOutputs HTTP server as a subprocess. Returns a guard that stops -/// the server on drop. -fn start_server() -> ServerGuard { - start_server_at("127.0.0.1") -} - -fn start_server_at(bind_address: &str) -> ServerGuard { - let binary_path = env!("CARGO_BIN_EXE_ado-aw"); - let port = free_port(bind_address); - let api_key = "test-api-key-12345".to_string(); - let temp_dir = tempfile::tempdir().unwrap(); - let dir_path = temp_dir.path().to_str().unwrap().to_string(); - - let mut cmd = Command::new(binary_path); - cmd.args([ - "mcp-http", - "--bind-address", - bind_address, - "--port", - &port.to_string(), - "--api-key", - &api_key, - &dir_path, - &dir_path, - ]); - cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::piped()); - - let mut child = cmd.spawn().expect("Failed to start mcp-http server"); - - // Consume stdout to read the startup output (SAFE_OUTPUTS_PORT=...) - let stdout = child.stdout.take().expect("Failed to capture stdout"); - let _stdout_thread = std::thread::spawn(move || { - let reader = std::io::BufReader::new(stdout); - for line in reader.lines() { - if line.is_err() { - break; - } - } - }); - - // Consume stderr to prevent buffer fill-up - let stderr = child.stderr.take().expect("Failed to capture stderr"); - let stderr_thread = std::thread::spawn(move || { - let reader = std::io::BufReader::new(stderr); - for line in reader.lines() { - if line.is_err() { - break; - } - } - }); - - // Wait for the server to become ready (up to 5 s) - let health_url = format!("http://{bind_address}:{port}/health"); - let client = reqwest::blocking::Client::builder() - .no_proxy() - .build() - .unwrap(); - for _ in 0..50 { - if client.get(&health_url).send().is_ok() { - return ServerGuard { - child, - port, - api_key, - _temp_dir: temp_dir, - stderr_thread: Some(stderr_thread), - }; - } - std::thread::sleep(Duration::from_millis(100)); - } - // Kill and panic if not ready - child.kill().ok(); - child.wait().ok(); - panic!("SafeOutputs HTTP server did not become ready within 5 s"); -} - -/// Send a JSON-RPC request to the SafeOutputs MCP endpoint. -fn mcp_request( - client: &reqwest::blocking::Client, - server: &ServerGuard, - body: serde_json::Value, - session_id: Option<&str>, -) -> reqwest::blocking::Response { - let mut req = client - .post(format!("http://127.0.0.1:{}/mcp", server.port)) - .header("Authorization", format!("Bearer {}", server.api_key)) - .header("Content-Type", "application/json") - .header("Accept", "text/event-stream, application/json"); - if let Some(sid) = session_id { - req = req.header("mcp-session-id", sid); - } - req.json(&body).send().expect("Failed to send MCP request") -} - -/// Perform the MCP initialize + initialized handshake, return session ID. -fn mcp_handshake(client: &reqwest::blocking::Client, server: &ServerGuard) -> Option { - let init_resp = mcp_request( - client, - server, - serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": { "name": "test-client", "version": "1.0" } - } - }), - None, - ); - assert!( - init_resp.status().is_success(), - "Initialize should succeed, got {}", - init_resp.status() - ); - - let session_id = init_resp - .headers() - .get("mcp-session-id") - .map(|v| v.to_str().unwrap().to_string()); - - // Send initialized notification - let mut notif_req = client - .post(format!("http://127.0.0.1:{}/mcp", server.port)) - .header("Authorization", format!("Bearer {}", server.api_key)) - .header("Content-Type", "application/json") - .header("Accept", "text/event-stream, application/json"); - if let Some(ref sid) = session_id { - notif_req = notif_req.header("mcp-session-id", sid); - } - let _ = notif_req - .json(&serde_json::json!({ - "jsonrpc": "2.0", - "method": "notifications/initialized" - })) - .send() - .unwrap(); - - session_id -} - -// ────────────────────────────────────────────────────────────── -// Tests -// ────────────────────────────────────────────────────────────── - -#[test] -fn test_health_endpoint_returns_ok() { - let server = start_server(); - let client = reqwest::blocking::Client::new(); - let resp = client - .get(format!("http://127.0.0.1:{}/health", server.port)) - .send() - .unwrap(); - assert_eq!(resp.status(), 200); - assert_eq!(resp.text().unwrap(), "ok"); -} - -#[test] -fn test_explicit_bind_address_is_used() { - let server = start_server_at("127.0.0.2"); - let client = reqwest::blocking::Client::builder() - .no_proxy() - .build() - .unwrap(); - let resp = client - .get(format!("http://127.0.0.2:{}/health", server.port)) - .send() - .unwrap(); - assert_eq!(resp.status(), 200); -} - -#[test] -fn test_auth_rejects_missing_token() { - let server = start_server(); - let client = reqwest::blocking::Client::new(); - let resp = client - .post(format!("http://127.0.0.1:{}/mcp", server.port)) - .header("Content-Type", "application/json") - .json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": {} - })) - .send() - .unwrap(); - assert_eq!(resp.status(), 401); -} - -#[test] -fn test_auth_rejects_wrong_token() { - let server = start_server(); - let client = reqwest::blocking::Client::new(); - let resp = client - .post(format!("http://127.0.0.1:{}/mcp", server.port)) - .header("Authorization", "Bearer wrong-key") - .header("Content-Type", "application/json") - .json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": {} - })) - .send() - .unwrap(); - assert_eq!(resp.status(), 401); -} - -#[test] -fn test_auth_accepts_correct_token() { - let server = start_server(); - let client = reqwest::blocking::Client::new(); - let resp = mcp_request( - &client, - &server, - serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": { "name": "test-client", "version": "1.0" } - } - }), - None, - ); - assert!( - resp.status().is_success(), - "Correct API key must be accepted with a 2xx response, got {}", - resp.status() - ); -} - -#[test] -fn test_health_endpoint_no_auth_required() { - let server = start_server(); - // Health endpoint should work without any auth header - let client = reqwest::blocking::Client::new(); - let resp = client - .get(format!("http://127.0.0.1:{}/health", server.port)) - .send() - .unwrap(); - assert_eq!(resp.status(), 200); -} - -#[test] -fn test_mcp_initialize_and_tools_list() { - let server = start_server(); - let client = reqwest::blocking::Client::new(); - let session_id = mcp_handshake(&client, &server); - - // List tools - let mut tools_req = client - .post(format!("http://127.0.0.1:{}/mcp", server.port)) - .header("Authorization", format!("Bearer {}", server.api_key)) - .header("Content-Type", "application/json") - .header("Accept", "text/event-stream, application/json"); - if let Some(ref sid) = session_id { - tools_req = tools_req.header("mcp-session-id", sid); - } - let tools_resp = tools_req - .json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/list", - "params": {} - })) - .send() - .unwrap(); - - assert!( - tools_resp.status().is_success(), - "tools/list should succeed, got {}", - tools_resp.status() - ); - - let body = tools_resp.text().unwrap(); - - // The response should contain our known tools - assert!(body.contains("noop"), "Should list noop tool, body: {body}"); - assert!( - body.contains("create-work-item"), - "Should list create-work-item tool, body: {body}" - ); - assert!( - body.contains("create-pull-request"), - "Should list create-pull-request tool, body: {body}" - ); - assert!( - body.contains("missing-tool"), - "Should list missing-tool tool, body: {body}" - ); - assert!( - body.contains("missing-data"), - "Should list missing-data tool, body: {body}" - ); - assert!( - body.contains("upload-workitem-attachment"), - "Should list upload-workitem-attachment tool, body: {body}" - ); - assert!( - body.contains("upload-build-attachment"), - "Should list upload-build-attachment tool, body: {body}" - ); - assert!( - body.contains("upload-pipeline-artifact"), - "Should list upload-pipeline-artifact tool, body: {body}" - ); -} - -#[test] -fn test_mcp_call_noop_tool() { - let server = start_server(); - let client = reqwest::blocking::Client::new(); - let session_id = mcp_handshake(&client, &server); - - // Call noop tool - let call_resp = mcp_request( - &client, - &server, - serde_json::json!({ - "jsonrpc": "2.0", - "id": 3, - "method": "tools/call", - "params": { - "name": "noop", - "arguments": { - "context": "Test run - no action needed" - } - } - }), - session_id.as_deref(), - ); - - assert!( - call_resp.status().is_success(), - "tools/call noop should succeed, got {}", - call_resp.status() - ); - - // Consume the full response body (SSE stream) to ensure the server-side - // handler has completed before we check the NDJSON file. - let _body = call_resp.text().unwrap(); - - // Verify NDJSON file was written - std::thread::sleep(Duration::from_millis(500)); - let ndjson_path = server._temp_dir.path().join("safe_outputs.ndjson"); - assert!( - ndjson_path.exists(), - "Safe outputs NDJSON file should exist at {:?}", - ndjson_path - ); - - let content = std::fs::read_to_string(&ndjson_path).unwrap(); - assert!( - content.contains("noop"), - "NDJSON should contain noop entry: {content}" - ); -} - -#[test] -fn test_mcp_call_create_work_item() { - let server = start_server(); - let client = reqwest::blocking::Client::new(); - let session_id = mcp_handshake(&client, &server); - - // Call create-work-item - let call_resp = mcp_request( - &client, - &server, - serde_json::json!({ - "jsonrpc": "2.0", - "id": 3, - "method": "tools/call", - "params": { - "name": "create-work-item", - "arguments": { - "title": "Test work item from integration test", - "description": "This is a test work item created during integration testing of the SafeOutputs HTTP server." - } - } - }), - session_id.as_deref(), - ); - - assert!( - call_resp.status().is_success(), - "tools/call create-work-item should succeed, got {}", - call_resp.status() - ); - - // Consume the full SSE response to ensure handler completion - let _body = call_resp.text().unwrap(); - - // Verify NDJSON file contains the work item - std::thread::sleep(Duration::from_millis(500)); - let ndjson_path = server._temp_dir.path().join("safe_outputs.ndjson"); - let content = std::fs::read_to_string(&ndjson_path).unwrap(); - assert!( - content.contains("create-work-item"), - "NDJSON should contain create-work-item entry: {content}" - ); - assert!( - content.contains("Test work item from integration test"), - "NDJSON should contain work item title: {content}" - ); -} diff --git a/tests/test_mcpg_local.sh b/tests/test_mcpg_local.sh deleted file mode 100755 index c84f8cd5..00000000 --- a/tests/test_mcpg_local.sh +++ /dev/null @@ -1,278 +0,0 @@ -#!/usr/bin/env bash -# test_mcpg_local.sh — Local smoke test for MCPG integration (no Docker required) -# -# This script validates the ado-aw components that interface with MCPG: -# 1. Compiles a sample agent and verifies MCPG markers in output YAML -# 2. Starts the optional SafeOutputs HTTP server directly -# 3. Sends MCP requests via curl -# 4. Verifies NDJSON safe output files are created -# -# Usage: -# ./tests/test_mcpg_local.sh -# ./tests/test_mcpg_local.sh --skip-compile # skip compilation step - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -TEMP_DIR=$(mktemp -d) -BINARY="" -SO_PID="" - -cleanup() { - if [ -n "$SO_PID" ]; then - kill "$SO_PID" 2>/dev/null || true - wait "$SO_PID" 2>/dev/null || true - fi - rm -rf "$TEMP_DIR" -} -trap cleanup EXIT - -log() { echo "==> $*"; } -pass() { echo " ✅ $*"; } -fail() { echo " ❌ $*"; exit 1; } - -# ─── Build ─────────────────────────────────────────────────────────── -log "Building ado-aw..." -cd "$PROJECT_DIR" -cargo build --quiet 2>/dev/null -BINARY="$PROJECT_DIR/target/debug/ado-aw" - -if [ ! -x "$BINARY" ]; then - fail "Binary not found at $BINARY" -fi -pass "Binary built: $BINARY" - -# ─── Step 1: Compile a sample agent ───────────────────────────────── -if [ "${1:-}" != "--skip-compile" ]; then - log "Step 1: Compiling sample agent..." - - FIXTURE="$SCRIPT_DIR/fixtures/minimal-agent.md" - OUTPUT_YAML="$TEMP_DIR/minimal-agent.yml" - - "$BINARY" compile --force "$FIXTURE" -o "$OUTPUT_YAML" - - if [ ! -f "$OUTPUT_YAML" ]; then - fail "Compiled YAML not created" - fi - - # Verify MCPG markers are resolved - if grep -q 'ghcr.io/github/gh-aw-mcpg' "$OUTPUT_YAML"; then - pass "MCPG image reference present" - else - fail "MCPG image reference missing" - fi - - if grep -q 'mcpg-config.json' "$OUTPUT_YAML"; then - pass "MCPG config file reference present" - else - fail "MCPG config file reference missing" - fi - - if grep -q '"entrypoint": "/usr/local/bin/ado-aw"' "$OUTPUT_YAML" && - grep -q '"/tmp/awf-tools/staging:/safeoutputs:rw"' "$OUTPUT_YAML"; then - pass "SafeOutputs stdio container configuration present" - else - fail "SafeOutputs stdio container configuration missing" - fi - - if grep -q -- '--network-isolation' "$OUTPUT_YAML" && - grep -q -- '--topology-attach "awmg-mcpg"' "$OUTPUT_YAML"; then - pass "AWF strict topology flags present" - else - fail "AWF strict topology flags missing" - fi - - if grep -q -- '--enable-host-access\|--legacy-security\|sudo -E .*awf' "$OUTPUT_YAML"; then - fail "Legacy AWF security flags found" - else - pass "No legacy AWF security flags" - fi - - if grep -q '"none"' "$OUTPUT_YAML" && - grep -q '"no-new-privileges"' "$OUTPUT_YAML"; then - pass "SafeOutputs container hardening present" - else - fail "SafeOutputs container hardening missing" - fi - - if grep -q 'SAFE_OUTPUTS_BIND_ADDRESS\|host.docker.internal' "$OUTPUT_YAML"; then - fail "Legacy SafeOutputs host HTTP plumbing found" - else - pass "No SafeOutputs host HTTP plumbing" - fi - - # Verify no unreplaced markers - if grep -v '\${{' "$OUTPUT_YAML" | grep -q '{{ '; then - fail "Unreplaced template markers found in compiled output" - else - pass "No unreplaced template markers" - fi - - # Verify no legacy MCP firewall references - if grep -qi 'mcp-firewall\|mcp_firewall' "$OUTPUT_YAML"; then - fail "Legacy MCP firewall references found" - else - pass "No legacy MCP firewall references" - fi -else - log "Step 1: Skipping compilation (--skip-compile)" -fi - -# ─── Step 2: Start SafeOutputs HTTP server ────────────────────────── -log "Step 2: Starting SafeOutputs HTTP server..." - -SO_DIR="$TEMP_DIR/safe-outputs" -mkdir -p "$SO_DIR" - -PORT=8199 -API_KEY="test-smoke-key-$(date +%s)" - -"$BINARY" mcp-http --port "$PORT" --api-key "$API_KEY" "$SO_DIR" "$SO_DIR" & -SO_PID=$! - -# Wait for server to be ready -READY=false -for i in $(seq 1 30); do - if curl -sf "http://127.0.0.1:$PORT/health" > /dev/null 2>&1; then - READY=true - break - fi - sleep 0.2 -done - -if [ "$READY" != "true" ]; then - fail "SafeOutputs HTTP server did not become ready" -fi -pass "SafeOutputs HTTP server running on port $PORT (PID: $SO_PID)" - -# ─── Step 3: Health check ─────────────────────────────────────────── -log "Step 3: Verifying health endpoint..." - -HEALTH=$(curl -sf "http://127.0.0.1:$PORT/health") -if [ "$HEALTH" = "ok" ]; then - pass "Health endpoint returns 'ok'" -else - fail "Health endpoint returned: $HEALTH" -fi - -# ─── Step 4: Auth check ───────────────────────────────────────────── -log "Step 4: Verifying auth enforcement..." - -HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' \ - -X POST "http://127.0.0.1:$PORT/mcp" \ - -H "Content-Type: application/json" \ - -H "Accept: text/event-stream, application/json" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}') - -if [ "$HTTP_CODE" = "401" ]; then - pass "Unauthenticated request rejected (401)" -else - fail "Expected 401, got $HTTP_CODE" -fi - -# ─── Step 5: MCP Initialize ───────────────────────────────────────── -log "Step 5: MCP Initialize handshake..." - -INIT_RESP=$(curl -sf -D "$TEMP_DIR/init-headers.txt" \ - -X POST "http://127.0.0.1:$PORT/mcp" \ - -H "Authorization: Bearer $API_KEY" \ - -H "Content-Type: application/json" \ - -H "Accept: text/event-stream, application/json" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"smoke-test","version":"1.0"}}}') - -SESSION_ID=$(grep -i 'mcp-session-id' "$TEMP_DIR/init-headers.txt" | tr -d '\r' | awk '{print $2}' || true) - -if [ -n "$SESSION_ID" ]; then - pass "Session initialized (ID: ${SESSION_ID:0:16}...)" -else - log " Warning: No session ID returned (stateless mode)" -fi - -# Send initialized notification -curl -sf -o /dev/null \ - -X POST "http://127.0.0.1:$PORT/mcp" \ - -H "Authorization: Bearer $API_KEY" \ - -H "Content-Type: application/json" \ - -H "Accept: text/event-stream, application/json" \ - ${SESSION_ID:+-H "mcp-session-id: $SESSION_ID"} \ - -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' || true - -pass "Initialized notification sent" - -# ─── Step 6: tools/list ───────────────────────────────────────────── -log "Step 6: Listing available tools..." - -TOOLS_RESP=$(curl -sf \ - -X POST "http://127.0.0.1:$PORT/mcp" \ - -H "Authorization: Bearer $API_KEY" \ - -H "Content-Type: application/json" \ - -H "Accept: text/event-stream, application/json" \ - ${SESSION_ID:+-H "mcp-session-id: $SESSION_ID"} \ - -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}') - -for tool in noop create-work-item create-pull-request missing-tool missing-data; do - if echo "$TOOLS_RESP" | grep -q "$tool"; then - pass "Tool '$tool' available" - else - fail "Tool '$tool' not found in tools/list response" - fi -done - -# ─── Step 7: tools/call noop ──────────────────────────────────────── -log "Step 7: Calling noop tool..." - -NOOP_RESP=$(curl -sf \ - -X POST "http://127.0.0.1:$PORT/mcp" \ - -H "Authorization: Bearer $API_KEY" \ - -H "Content-Type: application/json" \ - -H "Accept: text/event-stream, application/json" \ - ${SESSION_ID:+-H "mcp-session-id: $SESSION_ID"} \ - -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"noop","arguments":{"context":"Smoke test - all good"}}}') - -sleep 0.5 - -NDJSON="$SO_DIR/safe_outputs.ndjson" -if [ -f "$NDJSON" ]; then - pass "NDJSON file created: $NDJSON" -else - fail "NDJSON file not found" -fi - -if grep -q '"noop"' "$NDJSON"; then - pass "Noop entry found in NDJSON" -else - fail "Noop entry not in NDJSON. Content: $(cat "$NDJSON")" -fi - -# ─── Step 8: tools/call create-work-item ──────────────────────────── -log "Step 8: Calling create-work-item tool..." - -WI_RESP=$(curl -sf \ - -X POST "http://127.0.0.1:$PORT/mcp" \ - -H "Authorization: Bearer $API_KEY" \ - -H "Content-Type: application/json" \ - -H "Accept: text/event-stream, application/json" \ - ${SESSION_ID:+-H "mcp-session-id: $SESSION_ID"} \ - -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"create-work-item","arguments":{"title":"Smoke test work item title","description":"This is a smoke test work item with enough description length."}}}') - -sleep 0.5 - -if grep -q '"create-work-item"' "$NDJSON"; then - pass "Work item entry found in NDJSON" -else - fail "Work item entry not in NDJSON" -fi - -if grep -q 'Smoke test work item title' "$NDJSON"; then - pass "Work item title preserved in NDJSON" -else - fail "Work item title not found in NDJSON" -fi - -# ─── Summary ──────────────────────────────────────────────────────── -echo "" -log "All smoke tests passed! ✅" -echo "" -echo "NDJSON contents:" -cat "$NDJSON" From d2e5951bc90252a694edfc2d6b92d99c003f6d2a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:40:16 +0000 Subject: [PATCH 2/3] chore(mcp): finalize HTTP transport cleanup Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- AGENTS.md | 2 + Cargo.lock | 117 -------------------------------------- README.md | 2 + docs/local-development.md | 35 ++++++++++++ src/mcp.rs | 2 +- 5 files changed, 40 insertions(+), 118 deletions(-) create mode 100644 docs/local-development.md diff --git a/AGENTS.md b/AGENTS.md index 3aa8e338..f833df7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -391,6 +391,8 @@ index to jump to the right page. type codegen, the A2 design decision, the bundle env contract modelled in `src/compile/ado_bundle.rs`, and the `trigger-e2e/` gate-spec drift guard (kept in sync via `export-fact-catalog`). +- [`docs/local-development.md`](docs/local-development.md) — local development + setup notes. ## Development Guidelines diff --git a/Cargo.lock b/Cargo.lock index bf5adc8c..fd31a8b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,6 @@ dependencies = [ "ado-aw-derive", "anyhow", "async-trait", - "axum", "base64", "chrono", "clap", @@ -36,7 +35,6 @@ dependencies = [ "serde_yaml", "sha2", "similar", - "subtle", "tempfile", "tokio", "url", @@ -160,58 +158,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "axum" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" -dependencies = [ - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "base64" version = "0.22.1" @@ -1274,12 +1220,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "memchr" version = "2.7.6" @@ -1684,27 +1624,18 @@ checksum = "f542f74cf247da16f19bbc87e298cd201e912314f4083e88cdd671f44f5fcb53" dependencies = [ "async-trait", "base64", - "bytes", "chrono", "futures", - "http", - "http-body", - "http-body-util", "pastey", "pin-project-lite", - "rand", "rmcp-macros", "schemars", "serde", "serde_json", - "sse-stream", "thiserror", "tokio", - "tokio-stream", "tokio-util", - "tower-service", "tracing", - "uuid", ] [[package]] @@ -1911,17 +1842,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2032,19 +1952,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "sse-stream" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" -dependencies = [ - "bytes", - "futures-util", - "http-body", - "http-body-util", - "pin-project-lite", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2215,17 +2122,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -2252,7 +2148,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -2291,7 +2186,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2395,17 +2289,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "1.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "vcpkg" version = "0.2.15" diff --git a/README.md b/README.md index 0030c409..b722b2f6 100644 --- a/README.md +++ b/README.md @@ -764,6 +764,8 @@ index to jump to the right page. - [`docs/extending.md`](docs/extending.md) — adding new CLI commands, compile targets, front-matter fields, typed IR extensions, safe-output tools, first-class tools, and runtimes. +- [`docs/local-development.md`](docs/local-development.md) — local development + setup notes. --- diff --git a/docs/local-development.md b/docs/local-development.md new file mode 100644 index 00000000..4f53155d --- /dev/null +++ b/docs/local-development.md @@ -0,0 +1,35 @@ +# Local Development + +## Prerequisites + +- Rust 1.94.0 or later +- Git + +## Build and test + +From the repository root: + +```bash +cargo build +cargo test +cargo clippy --all-targets --all-features +``` + +## Test compilation + +Compile a workflow from source, then verify its generated pipeline: + +```bash +cargo run -- compile --force path/to/agent.md +cargo run -- check path/to/agent.lock.yml +``` + +## Documentation site + +The documentation site lives in `site/`: + +```bash +cd site +npm ci +npm run build +``` diff --git a/src/mcp.rs b/src/mcp.rs index c691e0d4..80d7befe 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::Result; use log::{debug, error, info, warn}; use rmcp::{ ErrorData as McpError, ServerHandler, ServiceExt, handler::server::tool::ToolRouter, From d879066b3e01c25f2de3a623e9f3b29d81842d91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:44:47 +0000 Subject: [PATCH 3/3] docs(mcp): remove remaining HTTP transport descriptions Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- AGENTS.md | 2 +- site/src/content/docs/reference/audit.mdx | 1 - site/src/content/docs/reference/targets.mdx | 2 +- src/compile/common.rs | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f833df7f..556717cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,7 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ ├── execute.rs # Stage 3 safe output execution │ ├── fuzzy_schedule.rs # Fuzzy schedule parsing │ ├── logging.rs # File-based logging infrastructure -│ ├── mcp.rs # SafeOutputs MCP server (stdio + HTTP) +│ ├── mcp.rs # SafeOutputs stdio MCP server │ ├── mcp_author/ # Author-facing read-only MCP server for local IDE/Copilot Chat integrations │ │ ├── mod.rs # Tool router + handlers for inspect/graph/deps/outputs/whatif/lint/catalog/trace/audit │ │ └── tests.rs # MCP-author integration / contract tests diff --git a/site/src/content/docs/reference/audit.mdx b/site/src/content/docs/reference/audit.mdx index a581d740..f498e927 100644 --- a/site/src/content/docs/reference/audit.mdx +++ b/site/src/content/docs/reference/audit.mdx @@ -58,7 +58,6 @@ URL-encoded project segments are decoded automatically. Both `t=` and `s=` are a │ └── logs/ │ ├── firewall/ # AWF Squid proxy logs │ ├── mcpg/ # MCP Gateway logs -│ ├── safeoutputs.log # SafeOutputs HTTP server log │ └── agent-output.txt # Filtered agent stdout ├── analyzed_outputs[_]/ # Detection stage artifacts │ ├── threat-analysis.json # Aggregate verdict + reasons diff --git a/site/src/content/docs/reference/targets.mdx b/site/src/content/docs/reference/targets.mdx index fae0a048..4551fba9 100644 --- a/site/src/content/docs/reference/targets.mdx +++ b/site/src/content/docs/reference/targets.mdx @@ -14,7 +14,7 @@ The `target` field in the front matter determines the output format and executio Generates a self-contained Azure DevOps pipeline with: - Full 3-job pipeline: `Agent` → `Detection` → `SafeOutputs` - AWF (Agentic Workflow Firewall) L7 domain whitelisting via Squid proxy + Docker -- MCP Gateway (MCPG) for MCP routing with SafeOutputs HTTP backend +- MCP Gateway (MCPG) for MCP routing, with SafeOutputs as a hardened stdio child - Setup/teardown job support - All safe output features (create-pull-request, create-work-item, etc.) diff --git a/src/compile/common.rs b/src/compile/common.rs index 0902a89b..72305c72 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -2307,7 +2307,7 @@ fn try_add_user_mcp( // Prevent user-defined MCPs from overwriting the reserved safeoutputs backend if name.eq_ignore_ascii_case("safeoutputs") { log::warn!( - "MCP name 'safeoutputs' is reserved for the safe outputs HTTP backend — skipping" + "MCP name 'safeoutputs' is reserved for the compiler-owned safe outputs backend — skipping" ); return Ok(()); }