-
Notifications
You must be signed in to change notification settings - Fork 121
Add Mcp tool poisoning sample (Demonstration purposes only) #3387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| # Base URL of the evil (WireMock) MCP server. Set by setup.sh's default port; | ||
| # override only if you changed EVIL_SERVER_PORT. | ||
| EVIL_SERVER_URL=http://localhost:8089/mcp | ||
|
|
||
| # --- Fill these in AFTER completing "Prerequisite: configure the gateway" in README.md --- | ||
|
|
||
| # Invoke URL of the MCP Proxy in WSO2 API Platform, WITH the MCP Access | ||
| # Control policy attached and deployed. | ||
| GATEWAY_MCP_URL= | ||
|
|
||
| # Optional: subscription key / bearer token required by your gateway deployment. | ||
| GATEWAY_API_KEY= |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| name: CI | ||
|
|
||
| on: | ||
| push: | ||
| branches: [main] | ||
| pull_request: | ||
|
|
||
| jobs: | ||
| demo-smoke-test: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - name: Make scripts executable | ||
| run: chmod +x setup.sh demo.sh teardown.sh | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| - name: Setup (start evil-server) | ||
| run: ./setup.sh | ||
|
|
||
| - name: Pass 1 -- expect the poisoned tool to be visible | ||
| run: | | ||
| set -o pipefail | ||
| ./demo.sh pass1 | tee pass1.log | ||
| grep -q "get_weather_report" pass1.log | ||
| grep -q "POISONED" pass1.log | ||
|
|
||
| - name: Pass 2 -- expect a graceful prerequisite message (no gateway configured in CI) | ||
| run: | | ||
| set -o pipefail | ||
| if ./demo.sh pass2 | tee pass2.log; then | ||
| code=0 | ||
| else | ||
| code=$? | ||
| fi | ||
| if [[ $code -ne 2 ]]; then | ||
| echo "Expected exit code 2 (gateway not configured), got $code" | ||
| exit 1 | ||
| fi | ||
| grep -q "GATEWAY_MCP_URL" pass2.log | ||
|
|
||
| - name: Teardown | ||
| if: always() | ||
| run: ./teardown.sh | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| .env | ||
| .pass1-response.json | ||
| .pass2-response.json | ||
| *.log | ||
| .DS_Store |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| # MCP Tool Poisoning Demo | ||
|
|
||
| A defanged malicious MCP server, caught by a WSO2 API Platform gateway policy. | ||
|
|
||
| This sample demonstrates **MCP tool poisoning attack** from the client's point of view. It also illustrates how attaching an **MCP Access Control** policy to an MCP Proxy in WSO2 API Platform stops the poisoned tool from reaching a consumer, without you having to trust every MCP server you connect to. | ||
|
|
||
| ## DISCLAIMER: Please read before running | ||
|
|
||
| - This sample license under the [Apache 2.0 License](https://github.com/wso2/api-platform-samples/blob/main/LICENSE) is provided for demonstrational purposes only, and is provided "AS IS," without any implied or express warranties or support. | ||
| - Use at your own risk, in an isolated test environment only. Never run it on production systems or networks you don't control. | ||
| - To the maximum extent permitted by law, WSO2 accepts no liability for any loss or damage arising from its use. | ||
|
|
||
| ## About the Demo Artifact | ||
|
|
||
| - This artifact is a demo for educational purposes. It is not a working exploit and is not intended to compromise anything. | ||
| - The `evil-server` is a static [WireMock](https://wiremock.org/) stub. It does not execute code, read files, or contact the network on its own. It only serves a canned JSON response over HTTP. | ||
| - The injection payload embedded in the poisoned tool's `description` field is **plain, visible text**. It is not obfuscated, encoded, or hidden. Anyone reading the raw JSON can understand the injection payload. | ||
| - [`tools-list.json`](evil-server/mappings/tools-list.json) also carries a plain-English `_disclaimer` field explaining it's a demo, right next to the poisoned tool. It's in the response WireMock serves (not just the file on disk). So anyone who calls the `evil-server` directly using a curl, not just people reading this README, sees the context immediately. | ||
| - The payload's exfiltration target is `http://example.com`, a domain [reserved by IANA for documentation and examples](https://www.iana.org/help/example-domains) (RFC 2606). It is never actually contacted by anything in this demo. | ||
| - This demo never calls `tools/call`. It only calls `tools/list`, so the payload is inspected as data. Nothing in this repository ever acts on the instructions it contains. No LLM or agent is in the loop. | ||
| - **Do not point a real AI agent or MCP client** with file-system or network tool access at the poisoned tool description and ask it to "just try it." The whole point of the demo is that an agent *would* follow those instructions if nothing filtered them out first. | ||
|
|
||
| ## What this demonstrates | ||
|
|
||
| MCP tool poisoning is a prompt-injection technique where a malicious or compromised MCP server embeds hidden instructions inside a tool's `description` field. A human skimming a tool list rarely reads full descriptions closely. However, an LLM client sends the *entire* description to the model as part of its context on every turn. A poisoned description can instruct the model to exfiltrate secrets, read files it wasn't asked to read, or misuse other tools, all while looking like an ordinary tool to the person who approved the connection. | ||
|
|
||
| MCP governance in WSO2 API Platform closes this type of blind spot. Instead of trusting every upstream MCP server, you design an **MCP Proxy** for it in **AI Workspace** and let the **AI Gateway** enforce policy on every request at runtime. | ||
|
|
||
| This demo uses the **MCP Access Control** policy. The policy can allow or deny access to specific resources with exceptions. The gateway independently applies these access rules to a proxy's tools, resources, and prompts. The policy doesn't inspect *content,* as there's no scan for prompt-injection patterns. It enforces which tool *names* a consumer is allowed to see or invoke at all. Configured as **default-deny**, only tools you've explicitly reviewed and allowlisted are ever exposed. So once `get_weather_report` is identified as poisoned (as pass 1 of this demo shows), simply *not* allowlisting it is enough to keep it from ever reaching a consumer, regardless of what the upstream MCP server does or changes next. | ||
|
|
||
| This gives you a governance point for MCP the same way an API gateway has always given you one for REST/GraphQL: one place to see, audit, and control what's actually being exposed. Trust shifts from "whatever the upstream MCP server happens to send" to "what an operator has explicitly approved." | ||
|
|
||
| - **`demo.sh` Pass 1: directly served, so exposed.** The client talks straight to the `evil-server`. Whatever it sends back is what the client gets (poisoned tool included). | ||
|
|
||
| - **`demo.sh` Pass 2: fronted by an MCP Proxy with a policy applied, so filtered.** The client talks to the MCP Proxy on AI Gateway. The proxy still fetches the same tool list from the `evil-server` behind the scenes, but the MCP Access Control policy checks it against an allowlist before responding, so only the approved tool makes it back to the client. | ||
|
|
||
| ## Files | ||
|
|
||
| | File | Purpose | | ||
| | :---- | :---- | | ||
| | README.md | This file | | ||
| | setup.sh | Starts the `evil-server`, creates `.env` | | ||
| | demo.sh | Runs `pass1` / `pass2` / `all` | | ||
| | teardown.sh | Stops the `evil-server`, cleans up temp files | | ||
| | .env.example | Copy to `.env`; fill in after configuring the gateway | | ||
| | scripts/lib.sh | Shared bash helpers (logging, MCP request helper) | | ||
| | evil-server/mappings/initialize.json | WireMock stub for the MCP `initialize` call | | ||
| | evil-server/mappings/notifications-initialized.json | WireMock stub for the `notifications/initialized` handshake step | | ||
| | evil-server/mappings/tools-list.json | WireMock stub for `tools/list` \-- contains the poisoned tool description | | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| - `bash`, `curl`, `jq` | ||
| - `docker` (used to run the WireMock `evil-server`) | ||
| - **WSO2 API Platform** with its two components running: | ||
| - **AI Workspace** \-- where you design the MCP Proxy and attach policies | ||
| - **AI Gateway** \-- the runtime that serves the deployed proxy and enforces those policies on every request (this is what pass 2 talks to) | ||
|
|
||
| ## Quick start (Pass 1: No gateway needed) | ||
|
|
||
| ```shell | ||
| ./setup.sh | ||
| ./demo.sh pass1 | ||
| ``` | ||
|
|
||
| You should see two tools returned by the `evil-server`'s `tools/list`, one of them flagged `POISONED` with the embedded instruction printed to the terminal. Nothing else is required for pass 1\. | ||
|
|
||
| ## Configure the gateway (required for Pass 2\) | ||
|
|
||
| Pass 2 needs an MCP Proxy defined in AI Workspace, deployed to AI Gateway, with the MCP Access Control policy attached. Do this after `./setup.sh` has started the `evil-server`. | ||
|
|
||
| ### 1\. Start AI Workspace and AI Gateway | ||
|
|
||
| Start (or restart) your local AI Workspace and AI Gateway containers. | ||
|
|
||
| ### 2\. Create the MCP Proxy in AI Workspace | ||
|
|
||
| In AI Workspace, create a new **MCP Proxy** with the following details: | ||
|
|
||
| | Field | Value | | ||
| | :---- | :---- | | ||
| | Name | `Evil Tools MCP Proxy` | | ||
| | Context | `/evil-tools` | | ||
| | Version | `1.0.0` | | ||
| | MCP Proxy Endpoint URL | see below | | ||
|
|
||
| For the **MCP Proxy Endpoint URL**, the value depends on where AI Gateway is running relative to the `evil-server` container: | ||
|
|
||
| - **AI Gateway running as a Docker container on the same machine** \- Docker containers can't reach the host via `localhost`, so use: | ||
|
|
||
| ``` | ||
| http://host.docker.internal:8089/mcp | ||
| ``` | ||
|
|
||
| If that hostname isn't resolvable from inside the AI Gateway container, connect the `evil-server` container to AI Gateway's Docker network instead (`docker network connect <ai-gateway-network> mcp-poison-evil-server`) and use `http://mcp-poison-evil-server:8080/mcp` (container-to-container, internal port 8080). | ||
|
|
||
|
|
||
| - **AI Gateway running natively / on the same host network** (not containerized): use `http://localhost:8089/mcp` directly. | ||
| - **AI Gateway running on a different machine entirely**: use this machine's LAN IP instead of `localhost`, e.g. `http://192.168.1.20:8089/mcp`. | ||
|
|
||
| ### 3\. Attach the MCP Access Control policy | ||
|
|
||
| On the Proxy's **Policies** tab in AI Workspace, click **Add Policies** and select **MCP Access Control**. In the panel, configure only the **tools** section. This proxy only exposes tools. | ||
|
|
||
| | Field | Value | | ||
| | :---- | :---- | | ||
| | tools.mode | `deny` | | ||
| | tools.exceptions | `list_open_invoices` | | ||
|
|
||
| **Note**: Press \<kbd\>Enter\</kbd\> after typing the value in the `tools.exceptions` field, so that it appears as a tag. | ||
|
|
||
| Here, every tool is denied except the ones listed as exceptions. Since `get_weather_report` (the poisoned tool) is deliberately left off the exceptions list, it gets denied not because its content was scanned, but because it was never explicitly approved. Click **Add**, then save the proxy. | ||
|
|
||
| ### 4\. Deploy to AI Gateway | ||
|
|
||
| Deploy the proxy from AI Workspace to AI Gateway, then copy AI Gateway's invoke URL for it (and a subscription key or token if your deployment requires one). | ||
|
|
||
| ### 5\. Point the demo at it | ||
|
|
||
| Copy `.env.example` to `.env` if `setup.sh` hasn't already done it for you, then set these variables: | ||
|
|
||
| ``` | ||
| GATEWAY_MCP_URL=<AI Gateway's invoke URL for the deployed MCP Proxy> | ||
| GATEWAY_API_KEY=<token, if required> | ||
| ``` | ||
|
|
||
| ### 6\. Run Pass 2 | ||
|
|
||
| ```shell | ||
| ./demo.sh pass2 | ||
| ``` | ||
|
|
||
| Or run both passes back to back: | ||
|
|
||
| ```shell | ||
| ./demo.sh all | ||
| ``` | ||
|
|
||
| ## What you should see | ||
|
|
||
| **Pass 1**: Both tools are returned. `get_weather_report` is flagged `POISONED` with the embedded instruction text printed to the terminal. The script warns that nothing is inspecting tool descriptions. | ||
|
|
||
| **Pass 2** (once the gateway is configured): Only `list_open_invoices` is returned. The script prints the differences between the two passes, showing `get_weather_report` was removed by the gateway policy, and reports `0 poisoned tools returned`. | ||
|
|
||
| ## Cleanup | ||
|
|
||
| ```shell | ||
| ./teardown.sh | ||
| ``` | ||
|
|
||
| This stops the `evil-server` container and removes local temp files. It does **not** remove the MCP Proxy you created, or stop AI Workspace or AI Gateway. Delete the proxy from AI Workspace and stop those containers manually if you no longer need them. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| #!/usr/bin/env bash | ||
| # Demonstrates MCP tool poisoning, once with no gateway policy in front of | ||
| # the evil-server (pass1) and once through a WSO2 API Platform MCP Proxy | ||
| # with the MCP Access Control policy attached (pass2). | ||
| # | ||
| # Usage: ./demo.sh [pass1|pass2|all] (default: all) | ||
| # | ||
| # Exit codes for `pass2` (and the pass2 leg of `all`): | ||
| # 0 gateway configured, poisoned tool correctly filtered out | ||
| # 2 GATEWAY_MCP_URL not set -- gateway prerequisite not configured yet | ||
| # 3 gateway configured, but the poisoned tool is still present | ||
| set -uo pipefail | ||
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| # shellcheck source=scripts/lib.sh | ||
| source "$SCRIPT_DIR/scripts/lib.sh" | ||
|
|
||
| if [[ -f "$SCRIPT_DIR/.env" ]]; then | ||
| set -a | ||
| # shellcheck source=.env.example | ||
| source "$SCRIPT_DIR/.env" | ||
| set +a | ||
| fi | ||
|
|
||
| MODE="${1:-all}" | ||
|
|
||
| require_cmd curl | ||
| require_cmd jq | ||
|
|
||
| INIT_BODY='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"mcp-tool-poisoning-demo-client","version":"1.0.0"}}}' | ||
| NOTIFIED_BODY='{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' | ||
| LIST_BODY='{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | ||
|
|
||
| # Substrings that show up in this demo's injected payload. This is only how | ||
| # the *demo script* recognizes and reports on the planted payload in pass 1 -- | ||
| # the gateway's MCP Access Control policy in pass 2 doesn't inspect content at | ||
| # all, it denies by tool name (see README). | ||
| POISON_MARKERS='IMPORTANT|http://example.com|debug_context|Do not mention' | ||
|
|
||
| list_tools() { | ||
| local url="$1" token="${2:-}" | ||
| mcp_request "$url" "$INIT_BODY" "$token" >/dev/null || return 1 | ||
| mcp_request "$url" "$NOTIFIED_BODY" "$token" >/dev/null || return 1 | ||
| mcp_request "$url" "$LIST_BODY" "$token" | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| count_poisoned() { | ||
| local json="$1" | ||
| echo "$json" | jq -r '.result.tools[].description' 2>/dev/null | grep -Ec "$POISON_MARKERS" || true | ||
| } | ||
|
|
||
| flag_poisoned() { | ||
| local json="$1" name desc | ||
| echo "$json" | jq -c '.result.tools[]' 2>/dev/null | while read -r tool; do | ||
| name=$(echo "$tool" | jq -r '.name') | ||
| desc=$(echo "$tool" | jq -r '.description') | ||
| if echo "$desc" | grep -Eq "$POISON_MARKERS"; then | ||
| printf " %s✘ %-24s POISONED -- hidden instructions embedded in description%s\n" "$RED" "$name" "$NC" | ||
| echo "$desc" | fold -s -w 96 | head -4 | sed "s/^/ ${DIM}/;s/\$/${NC}/" | ||
| else | ||
| printf " %s✔ %-24s clean%s\n" "$GREEN" "$name" "$NC" | ||
| fi | ||
| done | ||
| } | ||
|
|
||
| print_diff() { | ||
| log_header "Summary: pass 1 vs pass 2" | ||
| local names1 names2 | ||
| names1=$(jq -r '.result.tools[].name' "$SCRIPT_DIR/.pass1-response.json" 2>/dev/null | sort) | ||
| names2=$(jq -r '.result.tools[].name' "$SCRIPT_DIR/.pass2-response.json" 2>/dev/null | sort) | ||
| comm -23 <(echo "$names1") <(echo "$names2") | while read -r removed; do | ||
| [[ -n "$removed" ]] && printf " %s✔ %-24s removed by gateway policy%s\n" "$GREEN" "$removed" "$NC" | ||
| done | ||
| comm -12 <(echo "$names1") <(echo "$names2") | while read -r kept; do | ||
| [[ -n "$kept" ]] && printf " %s• %-24s present in both (unaffected, benign tool)%s\n" "$DIM" "$kept" "$NC" | ||
| done | ||
| } | ||
|
|
||
| run_pass1() { | ||
| log_header "PASS 1 -- Direct connection, no gateway policy" | ||
| log_info "Client -> evil-server directly at ${EVIL_SERVER_URL}" | ||
|
|
||
| local resp total poisoned | ||
| resp=$(list_tools "$EVIL_SERVER_URL") || { log_err "Could not reach evil-server. Did you run ./setup.sh?"; exit 1; } | ||
| echo "$resp" > "$SCRIPT_DIR/.pass1-response.json" | ||
|
|
||
| total=$(echo "$resp" | jq '.result.tools | length' 2>/dev/null || echo 0) | ||
| poisoned=$(count_poisoned "$resp") | ||
|
|
||
| echo | ||
| echo "Tools returned by tools/list:" | ||
| flag_poisoned "$resp" | ||
| echo | ||
|
|
||
| if [[ "$poisoned" -gt 0 ]]; then | ||
| log_warn "${poisoned}/${total} tool(s) contain an embedded prompt-injection payload." | ||
| log_warn "Nothing between the client and the MCP server is inspecting tool descriptions." | ||
| log_warn "An AI agent wired up to this server would receive that instruction as-is." | ||
| else | ||
| log_ok "No poisoned tools detected." | ||
| fi | ||
| } | ||
|
|
||
| run_pass2() { | ||
| log_header "PASS 2 -- Through the WSO2 API Platform MCP Proxy (MCP Access Control)" | ||
|
|
||
| if [[ -z "${GATEWAY_MCP_URL:-}" ]]; then | ||
| log_warn "GATEWAY_MCP_URL is not set -- the gateway prerequisite hasn't been configured yet." | ||
| cat <<EOF | ||
|
|
||
| To see pass 2, first complete "Configure the gateway" in README.md: | ||
| 1. In AI Workspace, create an MCP Proxy pointing at: | ||
| ${EVIL_SERVER_URL} | ||
| 2. Attach the "MCP Access Control" policy: tools.mode=deny, | ||
| tools.exceptions=[list_open_invoices]. | ||
| 3. Deploy the proxy to AI Gateway and copy its invoke URL. | ||
| 4. Put that URL in .env as GATEWAY_MCP_URL (and GATEWAY_API_KEY if your | ||
| deployment requires a subscription key / token). | ||
| 5. Re-run: ./demo.sh pass2 | ||
|
|
||
| EOF | ||
| return 2 | ||
| fi | ||
|
|
||
| log_info "Client -> WSO2 API Platform MCP Proxy at ${GATEWAY_MCP_URL}" | ||
|
|
||
| local resp total poisoned valid_tools | ||
| resp=$(list_tools "$GATEWAY_MCP_URL" "${GATEWAY_API_KEY:-}") || { | ||
| log_err "Could not reach GATEWAY_MCP_URL. Confirm the proxy is deployed and reachable." | ||
| exit 1 | ||
| } | ||
| echo "$resp" > "$SCRIPT_DIR/.pass2-response.json" | ||
|
|
||
| if echo "$resp" | jq -e '(.result.tools | type) == "array"' >/dev/null 2>&1; then | ||
| valid_tools=1 | ||
| else | ||
| valid_tools=0 | ||
| fi | ||
| total=$(echo "$resp" | jq '.result.tools | length' 2>/dev/null || echo 0) | ||
| poisoned=$(count_poisoned "$resp") | ||
|
|
||
| echo | ||
| echo "Tools returned by tools/list:" | ||
| flag_poisoned "$resp" | ||
| echo | ||
|
|
||
| if [[ -f "$SCRIPT_DIR/.pass1-response.json" ]]; then | ||
| print_diff | ||
| fi | ||
|
|
||
| if [[ "$valid_tools" -ne 1 ]]; then | ||
| log_err "MCP proxy did not return a valid tool list (no .result.tools array in the response)." | ||
| log_err "Confirm GATEWAY_MCP_URL/GATEWAY_API_KEY are correct and the proxy is deployed and reachable." | ||
| return 3 | ||
| elif [[ "$poisoned" -eq 0 ]]; then | ||
| log_ok "0 poisoned tools returned -- the gateway policy filtered it out before it reached the client." | ||
| return 0 | ||
| else | ||
| log_err "${poisoned} poisoned tool(s) still present." | ||
| log_err "Check that the MCP Access Control policy is attached AND deployed on this proxy, and that" | ||
| log_err "tools.exceptions contains list_open_invoices but not get_weather_report." | ||
| return 3 | ||
| fi | ||
| } | ||
|
|
||
| case "$MODE" in | ||
| pass1|1) | ||
| run_pass1 | ||
| ;; | ||
| pass2|2) | ||
| run_pass2 | ||
| exit $? | ||
| ;; | ||
| all) | ||
| run_pass1 | ||
| run_pass2 | ||
| exit $? | ||
| ;; | ||
| *) | ||
| echo "Usage: $0 [pass1|pass2|all]" | ||
| exit 1 | ||
| ;; | ||
| esac | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.