Skip to main content
BlogSecurity

Deteksi Shadow MCP dengan Policy Cloudflare Gateway

Cara Cloudflare Gateway menandai traffic MCP tak terkelola lewat satu selector, di mana rule block Portal-only berperan, dan apa yang tetap harus dikerjakan client hook dan server.

Matthews Wong

Software Engineer

Oktober 202612 menit baca
Deteksi Shadow MCP dengan Policy Cloudflare Gateway

Ringkasan Utama

Deteksi shadow MCP bekerja dengan mengklasifikasikan request hasil TLS inspection yang membawa header MCP-Protocol-Version, yang di Cloudflare Gateway muncul sebagai selector experimental.is_mcp. Padukan dengan rule block Portal-only, client hook, dan pengecekan di server, karena ketiadaan header tidak membuktikan apa pun dan traffic stdio, di luar jaringan, atau tanpa inspection tidak pernah sampai ke Gateway.

Seorang karyawan bisa menyambungkan Claude Code, Cursor, atau harness agent lain ke server MCP hanya dengan satu baris konfigurasi, tanpa persetujuan siapa pun. Traffic yang dihasilkan tidak punya bentuk yang jelas. Protokolnya tidak menjamin hostname atau path /mcp, jadi koneksi langsung ke tool server tampak seperti panggilan API HTTPS biasa.

Tulisan ini adalah pembacaan dekat atas post Cloudflare tanggal 14 Agustus 2026 tentang deteksi traffic MCP, tutorial Cloudflare yang menjadi dasarnya, halaman transport Streamable HTTP spesifikasi MCP 2026-07-28, dokumentasi hooks Claude Code, dan dokumentasi MCP Portals. Saya belum menjalankan selector baru ini di akun Gateway yang hidup, jadi setiap rule di bawah berasal dari sumber-sumber itu, dan apa pun yang saya simpulkan sendiri saya tandai sebagai inferensi.

Satu tool call, tiga tempat untuk melihatnya

Kerangka Cloudflare: satu tool call MCP punya tiga wujud. Di dalam client ia adalah keputusan memanggil sebuah tool dengan argumen tertentu. Di jaringan ia adalah HTTP POST yang membawa pesan JSON-RPC. Di server ia menjadi pemanggilan handler yang bisa membaca data atau mengubah state. Tiap wujud memberi tim security informasi yang berbeda, dan kesempatan berbeda untuk berkata tidak. Contoh request untuk tool cuaca di post tersebut menunjukkan apa yang ada di wire.

# What a 2026-07-28 tool call looks like on the wire. Only the three
# headers and the Host/path are visible without parsing the body.
POST /mcp HTTP/1.1
Host: tools.example.com
Authorization: Bearer <access-token>
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather

{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": { "city": "Austin" }
  }
}

# A 2025-11-25 call after initialize: the version header is there,
# but Mcp-Method and Mcp-Name are not, so the tool is only in the body.
POST /api HTTP/1.1
Host: tools.example.com
Content-Type: application/json
MCP-Protocol-Version: 2025-11-25

{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_weather"}}

Post itu menyebut argumen sebagai bagian paling sensitif: isinya bisa berupa query pencarian, source code, data pelanggan, atau instruksi untuk mengubah infrastruktur. Nama tool menunjukkan niat agent, sedangkan argumen menunjukkan data apa yang keluar. Response juga penting. Inspeksi request bisa menghentikan aksi berbahaya sebelum berjalan, tetapi inspeksi response dan logging hanya menunjukkan apa yang kembali ke agent.

Kenapa grep kata mcp di URL tidak lagi cukup

Pendekatan pertama Cloudflare, yang didokumentasikan di tutorial deteksi traffic MCP, mencari di Gateway HTTP logs lewat GraphQL Analytics API untuk hostname yang mengandung mcp dan path yang mengandung /mcp atau /sse. Post-nya mengakui dua kelemahan. Ia melewatkan server di URL biasa seperti tools.example.com/api, dan bisa cocok dengan layanan lain yang kebetulan memakai kata mcp di hostname. Metode ini masih berguna untuk client lama dan visibilitas historis.

# The tutorial's first-generation heuristic, as a GraphQL Analytics query.
# Window: at most 30 days per query. Fields are from the tutorial.
query MCPTrafficScan($accountTag: string, $since: string, $until: string) {
  viewer {
    accounts(filter: { accountTag: $accountTag }) {
      gatewayHttpRequestsAdaptiveGroups(
        filter: {
          datetime_geq: $since
          datetime_leq: $until
          OR: [
            { httpHost_like: "%mcp%" }
            { httpRequestURI_like: "%/mcp%" }
            { httpRequestURI_like: "%/sse%" }
          ]
        }
        limit: 10000
      ) {
        dimensions { httpHost action users }
        count
      }
    }
  }
}

# And the DLP body pattern for tools/call. Rust regex, first 1,024 bytes of a
# POST body only, so a method field later in the payload will not match.
"method"\s{0,5}:\s{0,5}"tools/call"

Tutorial itu juga membuat pola regex DLP untuk method JSON-RPC seperti initialize, tools/call, dan resources/read, serta policy Gateway HTTP yang memblokir berdasarkan profil DLP. Bagian batasannya lebih penting daripada polanya: DLP hanya memindai 1.024 byte pertama dari body request, hanya request POST, memakai regex Rust bukan sintaks JavaScript atau PCRE, dan riwayat GraphQL paling lama 30 hari per query. Tool call yang field method-nya berada setelah kilobyte pertama tidak akan cocok.

SinyalMenangkapTerlewatSyarat
Query hostname dan pathServer di host mcp atau path /mcp dan /sse, client lama, historiServer di path biasa, dan bisa salah cocokGateway HTTP logs dan GraphQL Analytics API
Regex DLP pada bodyMethod JSON-RPC di body POSTApa pun setelah 1.024 byte pertama, dan request non-POSTProfil DLP, policy Gateway HTTP, dan traffic yang didekripsi
Header MCP-Protocol-Version (experimental.is_mcp)Client remote yang patuh spesifikasi di hostname atau path apa punRequest initialize legacy yang pertama, dan client yang menghilangkan headerTLS inspection dan Cloudflare One Client di perangkat
Client hookServer remote dan stdio lokal, lengkap dengan nama tool dan argumenSetiap client yang belum Anda konfigurasi, dan perangkat tak terkelolaHook yang dipasang di setiap client

Jangan salin snippet profil DLP dari tutorial ke production. Seperti yang dipublikasikan, snippet itu membuat objek bernama dlpProfile, mengirimnya ke endpoint gateway/rules, tetapi yang dikirim adalah variabel dlpRule yang baru didefinisikan di bagian lain halaman. Ia juga memasang validasi luhn pada setiap pola tanpa penjelasan. Baca referensi DLP API terbaru sebelum membuat skripnya, dan anggap snippet itu sketsa ide.

Apa yang diletakkan spesifikasi 2026-07-28 di wire

Halaman Streamable HTTP pada spesifikasi 2026-07-28 membuat deteksi berbasis header menjadi praktis. Berikut persyaratan yang penting bagi kontrol di jaringan.

  • Setiap POST ke endpoint MCP WAJIB menyertakan header MCP-Protocol-Version, dan nilainya WAJIB sama dengan versi protokol di field _meta pada body request.
  • Mcp-Method wajib di semua request, dan Mcp-Name wajib untuk tools/call, resources/read, dan prompts/get. Nama yang bukan ASCII biasa dikirim sebagai base64 dengan sentinel =?base64?.
  • Header yang tidak cocok dengan body dibalas 400 Bad Request dan error JSON-RPC berkode -32020, HeaderMismatch. Alasannya: load balancer yang routing berdasarkan header dan server yang mengeksekusi berdasarkan body tidak boleh melihat request yang berbeda.
  • Handshake initialize dan sesi tingkat protokol dihapus. Server yang ingin mendukung client lebih lama dari 2025-06-18, yang tidak pernah mengirim header itu, BOLEH memperlakukan request tanpa header sebagai versi 2025-03-26.

Butir terakhir adalah jebakan bagi deteksi. Cloudflare mencatat bahwa spesifikasi 2025-11-25 mewajibkan header pada setiap request HTTP setelah inisialisasi, sehingga request pertama dalam alur legacy tidak membawanya dan tidak bisa diklasifikasikan hanya dari header. Spesifikasi juga meminta intermediary yang menegakkan policy berdasarkan header cermin untuk menolak request dengan header versi yang absen atau terlalu lama, alih-alih memercayai nilai yang belum divalidasi. Gateway yang bertumpu pada header sebaiknya dibaca dengan cara sama: hasil positif adalah bukti kuat, hasil negatif bukan bukti.

Tiga titik enforcement dan apa yang terlihat di masing-masing

Cloudflare menyebut tiga tempat untuk bertindak atas sebuah call: di dalam client sebelum request diserialisasi, di batas jaringan perangkat, dan di server sebelum handler tool berjalan. Tabel ini merangkum trade-off dari post tersebut. Tidak ada satu pun yang lengkap, dan itulah alasan menjalankan lebih dari satu.

Titik enforcementBisa melihatTidak bisa melihatTitik lemah
Hook di MCP clientServer, nama tool, dan argumen sebelum serialisasi, termasuk server stdio lokalClient dan perangkat yang tidak Anda kelolaKontrol harus diulang di setiap client; telemetri satu client bukan inventaris
Gateway di batas jaringanKoneksi langsung ke server remote mana pun di jalur terkelola, terkait ke user dan perangkatCall stdio lokal, traffic di luar jaringan, traffic Do Not InspectButuh TLS decryption, proxy di perangkat, dan origin yang memeriksa bahwa proxy dipakai
Server MCP sebelum handlerCaller yang sudah terautentikasi, pesan yang sudah di-parse, argumen yang sudah divalidasiServer yang tidak menerapkan pengecekanHanya melindungi server yang Anda jalankan. Ganti client tidak bisa melewatinya

Contoh lapisan server dari Cloudflare adalah WriteGuard, dipakai di seluruh server MCP internalnya: setiap tool punya risk tier dan status enabled atau disabled, read diteruskan apa adanya, write yang diizinkan mendapat atribusi agent dan audit event, dan aksi kritis diblokir sebelum handler-nya berjalan. Post itu menekankan bahwa logging setelah eksekusi bisa menjelaskan apa yang terjadi tetapi tidak bisa mencegahnya.

Gateway: selector, dashboard, dan rule Portal-only

Gateway mengklasifikasikan setiap request hasil TLS inspection dengan mencari header MCP-Protocol-Version dan menampilkannya sebagai selector boolean, experimental.is_mcp. Cloudflare menyatakan semua pelanggan Zero Trust melihat indikasi traffic MCP di Gateway HTTP logs dan bisa allow atau block berdasarkan selector itu tanpa memelihara daftar domain yang tampak seperti MCP. Nama selector memuat kata experimental, dan post-nya mengatakan dokumentasi akan diperbarui dengan detail protocol selector saat sinyal ini mencapai general availability, jadi bersiaplah kalau ia berubah.

Dashboard traffic MCP yang baru menjumlahkan request, unique user, dan unique server, memetakan server dari waktu ke waktu, memecah traffic per on-ramp sehingga traffic Portal terpisah dari koneksi langsung perangkat, dan mendaftar server teratas yang terlihat di luar Portal, yang disebut Cloudflare sebagai traffic shadow MCP yang paling penting. Filter-nya melompat ke Gateway HTTP logs untuk host atau user tertentu.

# Gateway HTTP policy: the baseline rule from Cloudflare's post.
# Selector: experimental.is_mcp is true when the MCP-Protocol-Version header
# is seen on a TLS-inspected request. Anything not stamped with the
# mcp_portal traffic source is, by definition, off the approved path.
Traffic:  experimental.is_mcp == true and not traffic.onramp in ("mcp_portal")
Action:   Block

# Portal side (docs): turn on "Route traffic through Cloudflare Gateway" for
# the whole portal, or per server through the secure_web_gateway field
# (defaults to false). Streamable HTTP upstreams only; SSE-only servers
# will not connect through Gateway.

Enforcement memakai selector Traffic Source di policy Gateway Network dan HTTP. Traffic yang di-route Portal lewat Gateway membawa traffic source mcp_portal, jadi satu rule memblokir semua yang MCP tetapi tidak datang lewat Portal. Dokumentasi Cloudflare menambahkan bahwa Gateway routing berupa toggle di Portal atau di satu server, hanya mendukung upstream Streamable HTTP, dan server yang hanya SSE tidak akan tersambung lewatnya. Inferensi saya: nyalakan routing Portal dan baca log sebelum enforcement, karena stempel mcp_portal hanya dijelaskan untuk traffic yang melewati Gateway.

Amati dulu sebelum memblokir. Cloudflare menyatakan Traffic Source dan deteksi MCP kini muncul di HTTP logs untuk traffic yang didekripsi tanpa policy sama sekali, jadi minggu pertama bisa read-only: daftar server di luar Portal, putuskan mana yang disetujui, pindahkan ke belakang Portal, baru nyalakan rule Block.

Perhatikan apa yang tidak bisa dibedakan rule ini. Shadow MCP adalah server yang tidak disetujui. Portal bypass adalah server yang disetujui tetapi diakses lewat URL upstream-nya, melewati Access policy, katalog tool terkurasi, dan audit trail Portal. Rule Block yang sama menyala untuk keduanya, tetapi memperbaiki bypass juga butuh origin yang menolak request langsung, lewat Access policy, pembatasan source IP, atau otorisasi yang diinisiasi oleh server MCP itu sendiri.

Lapisan client: hook Claude Code yang fail closed

Claude Code menamai tool MCP sebagai mcp__server__tool, dan hook PreToolUse bisa memeriksa nama itu sebelum call meninggalkan mesin. Itu mencakup server stdio lokal yang tidak pernah dilihat Gateway. Dua detail dari dokumentasi hooks menentukan apakah ini berhasil. Matcher yang hanya berisi huruf, angka, underscore, tanda hubung, spasi, koma, dan pipe dibandingkan sebagai string persis, sehingga mcp__memory saja tidak cocok dengan apa pun dan Anda butuh mcp__memory__.* untuk satu server utuh. Lalu hook yang crash atau timeout secara default tidak memblokir call; field onFailure bernilai block, tersedia sejak Claude Code v2.1.295, membuatnya fail closed.

// .claude/settings.json
// "mcp__.*" contains a dot, so it is evaluated as an unanchored regex.
// onFailure "block" (Claude Code v2.1.295+): a crashed or timed-out hook
// would otherwise let the call through.
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "mcp__.*",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/mcp-allowlist.sh",
            "args": [],
            "timeout": 10,
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# .claude/hooks/mcp-allowlist.sh
# Tool names arrive as mcp__<server>__<tool>; plugin servers as
# mcp__plugin_<plugin>_<server>__<tool>. Cut the server out, compare it
# to the approved list, and deny with exit 2 (stderr becomes the reason).
# Do not also print JSON: exit 2 blocks even if the JSON says allow.
APPROVED=" acme-portal memory "

input=$(cat)
tool=$(jq -r '.tool_name' <<<"$input")
server=${tool#mcp__}
server=${server%%__*}

case "$APPROVED" in
  *" $server "*) exit 0 ;;   # no decision: normal permission flow applies
esac

echo "Blocked: MCP server '$server' is not on the approved list" >&2
exit 2

Skrip di bawah membuat allowlist berdasarkan nama server. Ia menolak dengan exit code 2, yang mengirim stderr ke Claude sebagai alasan. Dokumentasi memperingatkan agar tidak mencampur exit 2 dengan output JSON dalam satu hook, karena exit 2 memblokir meski JSON-nya bilang allow. Ini kontrol sisi client untuk satu client: policy yang sama harus dibangun ulang untuk setiap harness lain yang dipakai tim, yaitu masalah standardisasi yang ditunjuk Cloudflare.

Di mana ini jebol

Ringkasan jujur dari sumber-sumbernya: setiap lapisan punya titik buta yang dinyatakan sendiri, dan titik butanya berbeda-beda.

  • Gateway tidak pernah melihat server stdio lokal, koneksi di luar jaringan, traffic Do Not Inspect, atau apa pun yang tidak melewatinya.
  • Header adalah sinyal positif yang kuat tetapi sinyal negatif yang lemah. Versi sebelum 2025-06-18 tidak mendefinisikannya, custom transport mungkin tidak membawanya, dan client yang tidak patuh bisa menghilangkannya.
  • Portal belum bisa menjangkau server di jaringan privat. Cloudflare menyatakan private routing lewat Gateway sedang dikembangkan aktif, jadi server MCP privat belum punya jalur terkelola hari ini.
  • Selector-nya masih experimental, dan fallback regex dari tutorial hanya memeriksa 1.024 byte pertama dari body POST.
  • Kontrol granular per tool dan pelaporan penggunaan tool di server yang dikenal maupun tidak dikenal digambarkan sebagai segera hadir, belum dirilis.

Rule block Portal-only adalah guardrail untuk perangkat terkelola di jalur terkelola, bukan perimeter. Agent di laptop pribadi, di dalam container dengan egress sendiri, atau lewat stdio berada di luarnya. Anggap rule ini menaikkan biaya kesalahan yang mudah, dan pertahankan pengecekan di sisi server untuk apa pun yang menulis data.

Yang dikerjakan hari Senin

Urutkan pekerjaan seperti Cloudflare: visibilitas dulu, baru tutup jalur yang tidak seharusnya ada.

  1. Pastikan TLS inspection dan Cloudflare One Client mencakup perangkat yang penting, lalu daftar apa saja yang dikecualikan oleh Do Not Inspect.
  2. Baca dashboard traffic MCP dan HTTP logs selama seminggu, lalu bandingkan setiap destinasi dengan server yang sudah Anda setujui.
  3. Pindahkan server yang disetujui ke belakang MCP Portal dan nyalakan Gateway routing untuknya, sambil memastikan tiap upstream mendukung Streamable HTTP.
  4. Tambahkan rule Block untuk traffic MCP di luar traffic source mcp_portal dan pantau log untuk server disetujui yang masih melewati Portal.
  5. Pasang client hook dengan onFailure bernilai block, dan letakkan pengecekan tingkat tool di depan setiap server buatan Anda yang bisa menulis.
# Run from a managed device, against a server you own. Replace the host.
# 1. With the headers the 2026-07-28 spec requires: Gateway should classify
#    it as MCP. If your rule is enforcing, expect a block, not the server reply.
curl -i -X POST https://tools.example.com/api \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/list" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# 2. Same call without the headers: the selector has nothing to key on.
#    A server that rejects header-less requests answers 400 itself.
curl -i -X POST https://tools.example.com/api \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Sebelum enforcement, uji policy dari perangkat terkelola dengan request tak berbahaya ke server milik Anda. Call pertama membawa header yang diwajibkan spesifikasi 2026-07-28, call kedua tanpa header. Kalau keduanya sampai ke server, selector tidak melihat traffic Anda, dan itu hal paling berharga untuk diketahui sebelum rollout.

Deteksi kini menjadi properti protokol, bukan daftar URL, tetapi hanya di jalur yang Anda inspeksi dan hanya untuk client yang mengikuti spesifikasi. Pakai selector Gateway untuk menemukan server shadow, rule Portal-only agar jalur yang disetujui jadi jalur termudah, dan client hook serta pengecekan server untuk semua yang tidak terlihat oleh jaringan.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Apa itu shadow MCP?

Shadow MCP adalah koneksi dari agent karyawan ke server MCP yang belum disetujui organisasi. Karyawan menemukan servernya di repository, panduan produk, atau pesan, lalu menambahkannya langsung ke client. Tim security pun tidak tahu tool apa yang diekspos dan data apa yang dikirim ke sana.

Bagaimana Cloudflare Gateway mendeteksi traffic MCP?

Gateway memeriksa setiap request hasil TLS inspection untuk header MCP-Protocol-Version dan menyetel selector boolean experimental.is_mcp menjadi true bila header itu ada. Selector tersebut bisa dipakai di policy Allow atau Block tanpa memelihara daftar domain yang tampak seperti MCP. Traffic yang tidak didekripsi, server stdio lokal, dan koneksi di luar jaringan tetap tidak terlihat.

Seperti apa rule block Portal-only itu?

Rule dasar dari Cloudflare adalah experimental.is_mcp == true and not traffic.onramp in ("mcp_portal") dengan aksi Block. Request yang di-route lewat MCP Portal dan Gateway membawa traffic source mcp_portal, sehingga hanya traffic MCP yang melewati Portal yang diblokir. Cloudflare menyarankan mengamati di HTTP logs dulu, karena deteksi dan traffic source muncul di sana tanpa policy apa pun.

Bisakah saya mengandalkan header MCP-Protocol-Version saja?

Tidak. Keberadaannya adalah indikator positif yang kuat untuk MCP, tetapi ketiadaannya tidak membuktikan apa pun. Versi sebelum 2025-06-18 tidak mendefinisikannya, request pertama dalam alur initialize legacy tidak membawanya, dan transport stdio atau custom mungkin tidak pernah mengirimnya.

Apakah hook Claude Code menggantikan deteksi di jaringan?

Tidak, keduanya menutup celah yang berbeda. Hook PreToolUse melihat server, tool, dan argumen sebelum call meninggalkan mesin serta mencakup server stdio lokal, tetapi hanya melindungi client yang memasangnya. Setel onFailure ke block, tersedia sejak Claude Code v2.1.295, agar hook yang crash tidak meloloskan call.