Hostile MCP
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
Hostile MCP LAB
Tier: Demo
Tier: Demo. A deliberately hostile MCP server, an offline victim that needs no model, and a pinned client shown side by side with an ungoverned one. It tells story S05, "the tool list changed overnight": a tool you approved last week is swapped for a poisoned one.
Repo: https://github.com/curtismu7/hostile-mcp-LAB
Nothing in the MCP protocol inspects tool metadata. The server serves the payloads and never scores or blocks. A defence can only sit where the metadata is consumed.
Run from a clone
Prerequisites: Node 26. Docker (OrbStack) only for the Docker form.
git clone https://github.com/curtismu7/hostile-mcp-LAB.git
cd hostile-mcp-LAB
npm ci
npm run build
npm run doctor
npm startRelated MCP server: mcp-chaos-rig
Ports, and what to do if the port is taken
Ports: hostile-mcp-LAB listens on 4701 (set with PORT, default 4701).
Port range for this app: 4700-4799.
If the port is taken:
1. Find what is using it:
lsof -iTCP:4701 -sTCP:LISTEN -n -P
2. If it is an old copy of this app, stop that process: kill <PID>.
3. If it is something else, stop it, or start this app on another port:
PORT=<other port> npm startOther variable: HOST_PORT=4701 (Docker host port). Keep both inside 4700-4799.
Release feature (simulated): entitlement filter and aggregate cap
The Gateway tab shows the hostile server with no gateway and through an entitlement filter plus an aggregate policy cap, both simulated in process. It is a Ping release claim (2026-11-02), UNVERIFIED on our tenant: the SE gateway is v1.261030 and does not have it. The app has no Mock/Live toggle and runs offline only, so the feature has no live path until the release is on our tenant. A banner on every tab, in presenter mode too, says so. See docs/gateway.md for the model, the tag on every statement (console swagger name, deck claim, lab-invented, lab reading) and the tests.
Status
Built and tested:
The hostile server, ported from the AI-DEMO2
standalone/hostile-mcp-server: Streamable HTTP and SSE,/health, 11 tools (6 with poisoned metadata, the rug-pull tool, a result-injection tool, 3 inert action stubs), one poisoned resource, one poisoned prompt. The wire output is checked byte for byte against the source.The offline, deterministic victim, the pinned client library, and the API, including
GET /api/health(demo-health/1) andGET /api/safety-scan.The web UI: gallery (badges: pinning, scanner, neither), run the victim (ungoverned and pinned side by side), the three-step rug-pull flow, the honest limits page, a ReactFlow diagram (movable boxes, Simulate, Reset, speed), stories S05 and S14 with the Allowed/Blocked switch and generic, airlines and banking packs, a presenter toggle, light and dark themes. Untrusted strings render as inert text only; a banner on every page says the server is hostile on purpose.
Docker on OrbStack, a shelf registry entry, a Learning Hub content pack, and a browser e2e script (all below).
Not verified: the optional real-model agent (npm run agent -w server) is copied from the source and tested offline only; it has not been run against a live model from this repo. The mcp-inspector was not run (see docs/clients.md). The Docker image has been run on OrbStack only. The shelf registration was checked against a temporary shelf instance, not your own.
Safety rules
This repo holds hostile content on purpose, so it is built to be inert:
The server binds
127.0.0.1only. It refuses any other address unlessHOSTILE_BINDis set.The server makes no outbound request of any kind. Tests block every non-loopback socket, DNS lookup and
fetchwhile exercising every tool, resource, prompt and API route, and a source scan fails on any outbound code path.Every host in every payload is a reserved, non-resolvable name: the
.invalidTLD, the.exampleTLD, or the apex ofexample.com/.net/.org(never a subdomain). A test scans every payload (and everything on the wire) for any other hostname, IP or email domain.The action tools (
create_transfer,delete_all_records,grant_admin) return a fixed fake result and touch nothing.The victim never executes a call and never POSTs anywhere. It returns the call it would make, as data.
No real credentials anywhere.
Run it
npm install
npm run doctor # node, bind, payload host scan, outbound settings, port
npm test # server (node:test) and web (vitest)
npm run build
npm start # http://127.0.0.1:4701/ (PORT to change)
npm run dev # API on 4701, Vite on 4702npm start serves the built UI from web/dist, so run npm run build first.
Ports start at 4700 so they stay clear of the other demos.
Docker
docker compose up --build -d # http://127.0.0.1:4701 ; HOST_PORT=4731 to change the published port
docker compose downNode 22, non-root, NODE_ENV=production. The server binds 0.0.0.0 inside the container (the image sets HOSTILE_BIND, the explicit opt-in) but compose publishes the port as 127.0.0.1:PORT:4701, so nothing off the machine can reach it. Do not widen that. The container has a health check on /api/health. See docs/safety.md.
Hosting the mock
The env switch is MOCK_ONLY (the family convention); it is the same thing the SE rulebook calls MODE=mock. Set MOCK_ONLY=1 where the rulebook says MODE=mock.
A hosted copy needs these settings, all explicit. The defaults stay loopback-only and none of this switches on by itself.
MOCK_ONLY=1 # refuse URL/credential carriers; per-visitor rug-pull state; mock banner
HOSTILE_BIND=0.0.0.0 # the opt-in to listen off loopback (the Docker image already sets it)
HOSTILE_ALLOWED_HOSTS=mock.hostile.example # comma list of Host names (no ports); any other Host gets 421
HOSTILE_TRUST_PROXY=1 # optional: number of trusted proxy hops in front (default 0 = off), see below
PORT=4701Without
HOSTILE_BINDthe server refuses a non-loopback address.MOCK_ONLYon a non-loopback bind refuses to start withoutHOSTILE_ALLOWED_HOSTS, so a hosted mock never answers an arbitrary Host (DNS rebinding). On loopback, with no list, there is no Host check (the local default).Readiness probe:
GET /api/healthreturns 200 with"state":"ready"(and"mode":"mock-only"when on). The probe is subject to the Host check: the kubelet probe must set aHostheader to an allowed name viahttpGet.httpHeaders, or the probe's own host must be added toHOSTILE_ALLOWED_HOSTS. The compose health check calls127.0.0.1, so add that name if you set the list there.Visitor isolation: each MCP client gets its own rug-pull counter from a session id the server mints on
initialize(Mcp-Session-Id, and anHttpOnly; SameSite=Laxcookie,SecurebehindX-Forwarded-Proto: https). The connection address is never a state key. A request with no id, or one the server did not issue, sees a fresh state. State is held in memory (5000 visitors by default, 30 minute TTL, least recently used evicted), so run one replica or use session affinity.Web UI sessions (the rug-pull page,
/api/mcp-session/*) belong to the visitor: the server mints anhm_uiHttpOnly cookie, a session id works only with the cookie that created it, each visitor holds at most 10 sessions and may create 30 a minute, and the global table holds 5000 (least recently used evicted). A client-invented cookie is ignored.Throttle source and the ingress: minting (both the MCP session id and the
hm_uicookie) is rate-limited per source. By default the source is the TCP connection, which behind an ingress is the ingress pod, so every visitor would share one bucket. SetHOSTILE_TRUST_PROXY=N, where N is the number of trusted proxy hops in front of the server (1 for a single ingress), and the source becomes the Nth entry from the right ofX-Forwarded-For; entries a client prepends are ignored. Leave it unset when nothing trusted sits in front, since the header is then client-controlled.Do not bake a
.envinto an image..dockerignoreexcludes it; keep it that way and pass settings as environment variables at run time.
Demo shelf
docs/shelf-entry.json is a registry entry for the SE shelf (demo-shelf), catalog id D6. Do not edit the shelf repo: copy the entry into the shelf's git-ignored .data/demos.local.json (or merge its demos array into an existing file) and set composeFile to this checkout's absolute path.
{
"demos": [
{
"id": "hostile-mcp",
"name": "Hostile MCP / rug-pull",
"catalogId": "D6",
"kind": "Demo",
"description": "A deliberately hostile MCP server, an offline victim, and a pinned client shown side by side with an ungoverned one: the tool that changed overnight.",
"url": "http://localhost:4701",
"healthPath": "/api/health",
"composeFile": "/absolute/path/to/hostile-mcp/docker-compose.yml",
"composeOverlays": []
}
]
}With the app running on 4701 the shelf shows it as Ready. Checked against a shelf instance started from demo-shelf on a free port with a temporary data directory.
Learning Hub content pack
content-pack.json (format demo-content-pack/1, schema copied to docs/content-pack.schema.json from the ID-JAG kit) holds the glossary (tool poisoning, tool shadowing, rug pull, trust on first use, inputSchema exfil sink, result-based injection, credential harvest, pinning), cards for the pages and the S05 and S14 stories. sources is empty on purpose: the only URLs in the source README are local, so none could be verified as public documentation.
npm run content-pack # regenerate (content/terms.json + the pages + the stories) and validate with ajv
node scripts/validate-content-pack.mjs [file]npm test fails if the committed pack is stale or does not validate.
Browser e2e
npm run build
npm run e2e # starts the server on E2E_PORT (default 4743), drives it, stops it
npm run e2e -- http://127.0.0.1:4701 # drive a server that is already runningDrives the gallery, the side-by-side run (including the day-one limit: the poison lands even with pinning), the three-step rug-pull flow with its reset, the limits page and the diagram in a real Chromium, and fails if the browser requests anything outside the app origin. Screenshots go to .data/e2e-screenshots (git-ignored). It is not run by CI. It uses the Chromium that the installed Playwright resolves (npx playwright install chromium if it is missing). E2E_CHROMIUM is only a fallback: set it to an executable path if no Playwright browser can be found.
Docs
docs/architecture.md: pieces, canonicalisation, verdicts, the two list counters.
docs/safety.md: every safety rule and the test that enforces it.
docs/limits.md: what pinning catches and does not.
docs/gateway.md: the entitlement filter and aggregate cap simulation, how each statement is tagged, the tests.
docs/gateway-why-it-matters.md: Challenge, Technical and Business lines (Ping claims, UNVERIFIED).
docs/threat-catalog.md: generated from the catalog data.
docs/clients.md: pointing a real MCP client or the inspector at it; how the Privilege demo defends the call path while pinning defends the metadata path.
The victim
POST /api/run-victim runs the S05 story once, deterministically, and returns the trace, what the victim saw, the calls it would make with a reason for each, and what the pinned client flagged. Rules (GET /api/victim-rules):
Tools are resolved by name; if two share a name, the last wins.
The benign task picks the tool whose name shares the most words with it. Action tools are never picked from a benign task.
Every argument with a schema default is filled with it. A default pointing off the approved origin is an exfiltration sink.
If the chosen tool asks for a password or code, the victim asks the user.
An
<IMPORTANT>block in the chosen tool's description is obeyed.A plain "ignore previous instructions ... call " is obeyed the same way.
Metadata only: it never sees call results, resources or prompts.
The victim is a stand-in for a trusting model, not a model. Argument values come from a simple pattern on the task.
The pinned client
Trust on first use. approve(tools) pins sha256(canonical({name, description, inputSchema})) (object keys sorted at every depth; required is treated as a set). check(tools) returns one verdict per tool:
Verdict | Meaning |
| Same hash as approved. |
| Same name and origin, different hash: the rug-pull. The field diff is included. |
| The name is already approved from another origin. |
| Never approved. |
| New or changed, and it carries a URL or email default (also const, enum, examples) pointing away from the approved origin. |
What pinning catches, and what it does not
Catches: the rug-pull, name shadowing, an off-origin sink added after approval, a new tool.
Does not catch: a poison already present when the tool was first approved (the approval is blind), result-based injection, poisoned resources and prompts, a tool a person approves again after a change. A scanner or a human has to vet the first list. Run GET /api/catalog for the answer per poison; pinningLimits is the same list as data.
API
Endpoint | Purpose |
|
|
| Runs the payload host scan now: |
| Each poison: what it does, how it lands, and whether a scanner and pinning catch it. |
| The victim rules and the pinning catch/limit lists. |
|
|
| The gateway tab's starting policy, callers, tasks, tags and the UNVERIFIED status. |
|
|
| New in-memory MCP session against the real server, with its own list counter. |
| tools/list. Returns the tools and, once approved, the pinned check. |
| Pin the last list. |
| End the session. |
The MCP endpoints keep the source's paths: POST / and POST /mcp (Streamable HTTP, stateless), GET /sse with POST /messages, GET /health. On those, the rug-pull counter is process-wide, as in the source: the first tools/list the process answers is clean, every later one is poisoned. The API sessions count per session.
Layout
server/src/poisons.js the payload catalog (copied, unchanged)
server/src/mcp/ tools and the MCP server (copied, with an explicit list counter)
server/src/safety.js loopback and reserved-host rules, payload scan
server/src/pinning.js the pinned client
server/src/victim.js the offline victim
server/src/session.js in-memory MCP sessions
server/src/scenario.js the S05 run behind /api/run-victim
server/src/gateway.js the entitlement filter and aggregate cap simulation (pure)
server/src/gateway-scenario.js the run and the request validation behind /api/gateway/run
server/tools/agent.mjs optional real-model victim (needs LLM_URL)
web/ React + Vite UI
content/terms.json glossary source for the content pack
scripts/ doctor, content pack, threat catalog, e2e
docs/ architecture, safety, limits, catalog, clients, shelf entry
Dockerfile, docker-compose.ymlLicence
MIT, from the source demo; see LICENSE. This is an independent community demo, not endorsed by Ping Identity.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
This server cannot be deployed
Maintenance
Related MCP Connectors
Security scanner for MCP servers. Detect vulnerabilities, prompt injection, and tool poisoning.
Self-hosted MCP server: 26 deterministic dev, security, and EVM tools.
Security research canary remote MCP server for owned-account testing.
Independent trust scores, tool surfaces and change history for MCP servers.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceA deliberately insecure MCP server designed as a pentest lab to demonstrate common vulnerabilities in MCP deployments.-
- AlicenseNot gradedqualityCmaintenanceA local MCP server that breaks on demand, allowing you to test your client against auth failures, disappearing tools, flaky responses, and token expiry from a web UI.20 npm10MIT
- FlicenseNot gradedqualityDmaintenanceMCP server with intentionally malicious capabilities for testing Cisco AI Defense supply chain scanning.-
- AlicenseCqualityDmaintenanceAn educational MCP server exposing shell command execution (PowerShell and sh) and a benign tool for learning about MCP tools, resources, and security risks like tool poisoning.33MIT