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
12 changes: 12 additions & 0 deletions samples/mcp-tool-poisoning-demo/.env.example
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=
43 changes: 43 additions & 0 deletions samples/mcp-tool-poisoning-demo/.github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: CI
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Comment thread
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
5 changes: 5 additions & 0 deletions samples/mcp-tool-poisoning-demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.env
.pass1-response.json
.pass2-response.json
*.log
.DS_Store
151 changes: 151 additions & 0 deletions samples/mcp-tool-poisoning-demo/README.md
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.
182 changes: 182 additions & 0 deletions samples/mcp-tool-poisoning-demo/demo.sh
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"
}
Comment thread
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
Loading
Loading