"Yang sulit dari menyambungkan AI ke backend bukan membuat modelnya bisa memanggil fungsi kamu. Yang sulit adalah memutuskan fungsi mana yang boleh dipanggil, dengan batas apa, dan bagaimana server kamu bersikap ketika model salah memanggilnya."
Tentang Artikel Ini
Di artikel Integrasi LLM API untuk Backend Engineer, backend kita yang memegang kendali: kita yang menyusun prompt, kita yang memanggil API, kita yang memutuskan data apa yang disuapkan ke model. Alurnya satu arah dan sepenuhnya kita yang mengatur.
MCP membalik arah itu. Backend kamu berhenti jadi pemanggil, dan berubah jadi sesuatu yang bisa dipanggil — oleh Claude Desktop, oleh Claude Code, oleh agent yang kamu tulis sendiri, atau oleh klien lain yang belum ada waktu artikel ini ditulis. Kamu tidak lagi menulis integrasi untuk satu klien tertentu. Kamu mengumumkan kapabilitas, lalu klien mana pun yang bicara protokol yang sama bisa memakainya.
Kalau kamu pernah menyentuh Language Server Protocol, bentuknya akan terasa familiar: satu server bahasa, dipakai VS Code, Neovim, dan Emacs sekaligus, tanpa plugin terpisah untuk masing-masing. MCP meminjam bentuk yang sama untuk urusan yang berbeda — dan memang desainnya lahir dari sana. Perbedaannya, "editor" di sini adalah model, dan yang dia butuhkan bukan definisi simbol melainkan akses terkontrol ke sistem kamu.
Kita akan bangun MCP server untuk toko-api, project yang sama dari artikel-artikel sebelumnya: model bisa mencari produk, mengecek stok, membaca detail produk, sampai menonaktifkan produk dari katalog. Setelah selesai, kamu akan bisa:
- Menjelaskan bentuk protokol MCP tanpa harus menghafal spesifikasinya
- Membangun MCP server dengan transport stdio dan HTTP, dan tahu kapan memilih yang mana
- Membedakan tiga primitif MCP — tool, resource, prompt — dan tidak menumpuk semuanya jadi tool
- Mengembalikan error dalam bentuk yang bisa model perbaiki sendiri
- Menandai tool yang berbahaya supaya klien meminta konfirmasi sebelum menjalankannya
- Mengamankan server HTTP dengan autentikasi dan proteksi DNS rebinding
- Menguji server lewat MCP Inspector maupun client SDK yang kamu tulis sendiri
Prasyarat: Sudah menyelesaikan Integrasi LLM API untuk Backend Engineer — kita pakai toko-api dan tabel products dari sana. Node.js 20 ke atas (bukan 18; alasannya kita bahas di Bab 9, dan ini bukan sekadar anjuran).
Daftar Isi
- Masalah yang Sebenarnya Diselesaikan MCP
- Bentuk Protokolnya: JSON-RPC, Tiga Primitif, Dua Transport
- Server Pertama: stdio dan Satu Tool
- Menyambung ke Data Asli dan Bentuk Error yang Benar
- Resource dan Prompt: Dua Primitif yang Sering Dilewati
- Tool yang Mengubah Data: Annotations dan Batas Kewenangan
- Transport HTTP: Saat Server Dipakai Lebih dari Satu Orang
- Menguji Server: Inspector dan Client Sendiri
- Jebakan yang Baru Terasa Belakangan
Bab 1: Masalah yang Sebenarnya Diselesaikan MCP
Function Calling Sudah Ada, Lalu Kenapa Butuh Protokol Lagi?
Pertanyaan ini wajar, dan saya rasa layak dijawab lebih dulu sebelum kita menulis satu baris kode pun — karena kalau jawabannya tidak jelas, MCP cuma akan terasa seperti lapisan tambahan yang merepotkan.
Function calling (atau tool use) sudah lama tersedia di OpenAI maupun Anthropic API. Kamu mendeklarasikan daftar fungsi beserta skemanya, model memilih salah satu, kamu eksekusi, lalu hasilnya kamu kirim balik. Itu jalan, dan untuk aplikasi yang kamu tulis sendiri dari ujung ke ujung, itu sudah cukup.
Masalahnya muncul begitu ada lebih dari satu pemakai. Misalkan kamu sudah punya fungsi cariProduk dan cekStok di toko-api. Sekarang tim kamu ingin:
- Tim support memakainya dari Claude Desktop sambil menjawab chat pelanggan
- Kamu sendiri memakainya dari Claude Code waktu menelusuri data
- Ada agent internal yang jalan terjadwal untuk mengecek stok menipis
Dengan function calling murni, tiga pemakai itu berarti tiga potong kode integrasi terpisah — masing-masing dengan definisi skema, eksekusi, dan penanganan error sendiri. Begitu kamu menambah satu fungsi atau mengubah satu parameter, kamu mengubahnya di tiga tempat. Yang lebih repot: Claude Desktop tidak akan pernah bisa memanggil fungsi internal kamu, karena tidak ada cara mendaftarkannya ke sana.
MCP memindahkan deklarasi itu ke satu tempat. Kamu menulis satu server, mendaftarkannya sekali ke tiap klien, dan daftar tool-nya diambil klien lewat protokol. Tambah tool baru? Klien melihatnya tanpa kamu menyentuh kode klien mana pun.
Yang Berubah dari Cara Berpikir Kamu
Ada pergeseran yang menurut saya lebih penting daripada detail teknis mana pun di artikel ini: MCP server bukan API internal, dan juga bukan API publik dalam pengertian biasa. Pemanggilnya bukan frontend yang kamu tulis sendiri dan perilakunya bisa kamu prediksi. Pemanggilnya adalah model, yang memilih tool berdasarkan deskripsi yang kamu tulis, dan sesekali akan memilih yang salah.
Konsekuensinya langsung terasa di keputusan desain:
- Deskripsi tool itu bagian dari prompt, bukan dokumentasi. Kalimat yang kamu tulis di
descriptionikut masuk ke konteks model dan memengaruhi pilihannya. Deskripsi yang asal akan menghasilkan pemanggilan yang asal. - Error harus bisa dibaca dan ditindaklanjuti model. "Internal server error" membuat model buntu. "Produk 99 tidak ditemukan, coba
cari_produkdulu" membuatnya memperbaiki sendiri langkah berikutnya. - Kewenangan harus eksplisit. Tool yang menghapus data tidak boleh terlihat sama polosnya dengan tool yang membaca data.
Tiga hal ini yang akan terus muncul sepanjang artikel.
Bab 2: Bentuk Protokolnya: JSON-RPC, Tiga Primitif, Dua Transport
Lapisan Pesannya: JSON-RPC 2.0
Di bawahnya, MCP memakai JSON-RPC 2.0 — format yang sudah tua, sederhana, dan sengaja tidak pintar. Satu pesan punya method, params, dan id; balasannya membawa result atau error dengan id yang sama.
Supaya tidak terlalu abstrak, ini pertukaran sungguhan dengan server yang akan kita bangun. Klien membuka sesi:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}Server menjawab dengan versi protokol yang disepakati dan daftar kapabilitas yang dia punya:
{"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true},"resources":{"listChanged":true},"prompts":{"listChanged":true}},"serverInfo":{"name":"toko-mcp","version":"1.0.0"}},"jsonrpc":"2.0","id":1}Perhatikan capabilities. Server mengumumkan bahwa dia menyediakan tools, resources, dan prompts — dan bahwa ketiganya bisa berubah di tengah sesi (listChanged). Klien memakai informasi ini untuk tahu apa yang boleh dia tanyakan selanjutnya. Tidak ada penemuan lewat tebak-tebakan URL seperti di REST; semuanya dinegosiasikan di awal.
Kabar baiknya, SDK resmi menangani seluruh lapisan ini. Kamu tidak akan menulis jsonrpc: "2.0" sekali pun. Tapi memahami bentuknya membantu waktu ada yang aneh dan kamu perlu membaca log mentah.
Tiga Primitif, dan Siapa yang Mengendalikannya
Di sinilah orang paling sering keliru — termasuk saya waktu pertama kali mencoba. Godaannya besar untuk menjadikan semuanya tool, karena tool yang paling mudah dibayangkan. Padahal pembedanya jelas kalau dilihat dari siapa yang memutuskan pemakaiannya:
| Primitif | Dikendalikan oleh | Dipakai untuk |
|---|---|---|
| Tool | Model | Aksi yang model putuskan sendiri kapan dipanggil |
| Resource | Aplikasi/klien | Data yang dibaca dan dimasukkan ke konteks |
| Prompt | User | Template alur kerja yang user pilih secara sadar |
Tool dipanggil model atas inisiatifnya sendiri, berdasarkan deskripsi yang kamu tulis. cari_produk cocok jadi tool — model yang memutuskan kapan perlu mencari.
Resource lebih mirip berkas yang bisa dibaca, dialamatkan lewat URI seperti produk://2. Klien yang memutuskan kapan memuatnya — misalnya user melampirkan satu produk ke percakapan. Modelnya tidak memanggil resource atas kemauan sendiri.
Prompt adalah template yang user pilih, biasanya muncul sebagai perintah slash di klien. Bukan sesuatu yang model aktifkan sendiri.
Kalau ragu, pertanyaan yang biasanya menyelesaikan kebingungan: apakah model yang seharusnya memutuskan ini dipanggil? Kalau ya, tool. Kalau yang memutuskan user atau aplikasi, resource atau prompt.
Dua Transport
MCP bisa berjalan di atas dua transport, dan pilihannya menentukan model keamanan kamu:
stdio — server jalan sebagai proses anak dari klien, bertukar pesan lewat stdin/stdout. Tidak ada port, tidak ada jaringan. Klien yang menjalankan prosesnya, jadi siapa pun yang bisa menjalankan klien sudah punya akses. Ini default yang tepat untuk server yang dipakai satu orang di satu mesin.
Streamable HTTP — server jalan sebagai layanan HTTP biasa, klien terhubung lewat jaringan. Ini yang kamu butuhkan kalau server dipakai beberapa orang atau jalan di infrastruktur terpisah. Konsekuensinya, kamu kembali berurusan dengan autentikasi, otorisasi, dan semua hal yang biasa kamu pikirkan untuk endpoint publik.
Kita akan bangun keduanya, dengan logika server yang sama persis — salah satu keuntungan desain protokol ini.
Bab 3: Server Pertama: stdio dan Satu Tool
Setup
mkdir toko-mcp && cd toko-mcp
npm init -y
npm install @modelcontextprotocol/sdk zodSDK yang kita pakai di artikel ini versi 1.32.0. zod bukan tambahan opsional — SDK mendeklarasikannya sebagai peer dependency (^3.25 || ^4.0) dan memakainya untuk mendefinisikan skema input tool sekaligus memvalidasi argumen yang masuk.
Atur package.json supaya memakai ES Modules, sama seperti toko-api:
{
"name": "toko-mcp",
"version": "1.0.0",
"type": "module",
"scripts": {
"start:stdio": "node src/stdio.js",
"start:http": "node src/http.js"
}
}Tool Pertama
Kita mulai dari satu tool saja — mencari produk — supaya bentuknya terlihat jelas sebelum ditambah yang lain.
// src/server.js
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { cariProduk } from './produk-repo.js';
const rupiah = (n) => `Rp${n.toLocaleString('id-ID')}`;
function ringkas(p) {
return `#${p.id} ${p.name} — ${rupiah(p.price)}, stok ${p.stock}`;
}
export function buatServer() {
const server = new McpServer({ name: 'toko-mcp', version: '1.0.0' });
server.registerTool(
'cari_produk',
{
title: 'Cari produk',
description:
'Cari produk aktif berdasarkan kata kunci pada nama atau deskripsi. ' +
'Pakai ini untuk menemukan id produk sebelum memanggil tool lain.',
inputSchema: {
q: z.string().min(2).max(100).describe('Kata kunci, contoh: "kopi"'),
limit: z.number().int().min(1).max(20).default(5).describe('Jumlah hasil maksimal'),
},
annotations: { readOnlyHint: true },
},
async ({ q, limit }) => {
const hasil = await cariProduk({ q, limit });
if (hasil.length === 0) {
return { content: [{ type: 'text', text: `Tidak ada produk untuk "${q}".` }] };
}
return { content: [{ type: 'text', text: hasil.map(ringkas).join('\n') }] };
},
);
return server;
}Ada beberapa keputusan di potongan ini yang sengaja, bukan kebetulan.
buatServer() adalah factory, bukan singleton. Di Bab 7 kita akan membuat instance server baru per request HTTP. Menulisnya sebagai fungsi sejak awal menghemat satu refactor.
Kalimat kedua di description menyebut tool lain. "Pakai ini untuk menemukan id produk sebelum memanggil tool lain" — itu bukan basa-basi. Model membaca deskripsi ini waktu memilih, dan kalimat itu mengarahkannya memakai cari_produk dulu alih-alih menebak-nebak angka id. Deskripsi tool adalah tempat kamu menuliskan alur kerja yang kamu harapkan.
.describe() di tiap field ikut terkirim ke model. Skema zod diterjemahkan jadi JSON Schema di tools/list, lengkap dengan deskripsinya. Contoh nilai seperti 'kopi' di situ mengurangi tebakan.
Batasnya ditulis di skema, bukan di dalam handler. min(2), max(100), max(20) divalidasi SDK sebelum handler kamu jalan. Argumen yang tidak lolos tidak akan pernah sampai ke query database.
Menyalakannya lewat stdio
// src/stdio.js
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { buatServer } from './server.js';
await buatServer().connect(new StdioServerTransport());
console.error('toko-mcp siap di stdio'); // stderr, bukan stdoutDua baris, dan server kamu sudah bisa dipakai klien MCP mana pun.
Perhatikan console.error, bukan console.log. Di transport stdio, stdout milik protokol — tiap baris di sana diharapkan berupa pesan JSON-RPC. Apa persisnya yang terjadi kalau kamu salah tulis, kita bahas di Bab 9, dan hasilnya mungkin tidak seperti yang kamu duga.
Mendaftarkannya ke Klien
Untuk Claude Code, lewat CLI:
claude mcp add toko -- node /path/ke/toko-mcp/src/stdio.jsUntuk Claude Desktop, lewat claude_desktop_config.json:
{
"mcpServers": {
"toko": {
"command": "node",
"args": ["/path/ke/toko-mcp/src/stdio.js"]
}
}
}Pakai path absolut. Klien menjalankan proses ini dengan working directory yang belum tentu sama dengan milik kamu, dan path relatif adalah penyebab paling sering dari server yang "tidak muncul" tanpa pesan error yang jelas.
Bab 4: Menyambung ke Data Asli dan Bentuk Error yang Benar
Repo Produk
produk-repo.js sengaja dipisah dari server.js supaya logika MCP tidak bercampur dengan query. Isinya query biasa ke tabel products dari artikel #02:
// src/produk-repo.js
import { pool } from './db.js'; // pool pg yang sama dengan toko-api
// node-postgres mengembalikan kolom NUMERIC sebagai string supaya presisinya
// tidak hilang — jadi price perlu dikonversi sebelum diformat.
const petakan = (row) => ({ ...row, price: Number(row.price) });
export async function cariProduk({ q, limit }) {
const { rows } = await pool.query(
`SELECT id, name, description, price, stock, sku
FROM products
WHERE is_active = true
AND (name ILIKE $1 OR description ILIKE $1)
ORDER BY name
LIMIT $2`,
[`%${q}%`, limit],
);
return rows.map(petakan);
}
export async function ambilProduk(id) {
const { rows } = await pool.query(
`SELECT id, name, description, price, stock, sku
FROM products
WHERE id = $1 AND is_active = true`,
[id],
);
return rows[0] ? petakan(rows[0]) : null;
}Baris Number(row.price) itu kecil tapi menyelamatkan. price didefinisikan NUMERIC(12, 2) di artikel #02, dan node-postgres mengembalikan tipe numeric sebagai string — bukan karena bug, tapi karena NUMERIC bisa menampung angka di luar jangkauan presisi Number JavaScript. Tanpa konversi itu, toLocaleString('id-ID') di helper rupiah akan jalan di atas string dan menghasilkan keluaran yang salah secara diam-diam.
Error yang Bisa Model Perbaiki
Sekarang tool kedua, dan di sinilah perbedaan MCP dari REST API biasa paling terasa:
server.registerTool(
'cek_stok',
{
title: 'Cek stok produk',
description: 'Ambil stok terkini satu produk berdasarkan id.',
inputSchema: { produk_id: z.number().int().positive() },
annotations: { readOnlyHint: true },
},
async ({ produk_id }) => {
const p = await ambilProduk(produk_id);
if (!p) {
return {
isError: true,
content: [
{ type: 'text', text: `Produk ${produk_id} tidak ditemukan. Coba cari_produk dulu.` },
],
};
}
return { content: [{ type: 'text', text: ringkas(p) }] };
},
);Yang penting di sini isError: true yang dikembalikan sebagai hasil normal, bukan throw. Perbedaannya nyata, dan paling gampang dilihat dari responsnya.
Produk yang tidak ada mengembalikan ini:
{"content":[{"type":"text","text":"Produk 99 tidak ditemukan. Coba cari_produk dulu."}],"isError":true}Model menerima teks itu sebagai hasil pemanggilan, membacanya, dan bisa langsung mengambil keputusan berikutnya — memanggil cari_produk seperti yang disarankan. Alur percakapan tidak putus.
Bandingkan dengan argumen yang gagal validasi skema, yang ditolak SDK sebelum handler kamu jalan:
{"content":[{"type":"text","text":"MCP error -32602: Input validation error: Invalid arguments for tool cari_produk: Too small: expected string to have >=2 characters at q"}],"isError":true}Pesannya juga sampai ke model, tapi perhatikan bentuknya — itu error protokol dengan kode JSON-RPC -32602. Berguna untuk debugging, dan kebetulan cukup deskriptif di sini, tapi bukan kalimat yang kamu kendalikan.
Pegangan yang saya pakai: throw untuk hal yang model tidak bisa perbaiki (database mati, konfigurasi salah), isError untuk hal yang model bisa tindak lanjuti (tidak ditemukan, input di luar jangkauan, stok habis). Yang pertama memang seharusnya meledak dan masuk log kamu. Yang kedua bagian normal dari percakapan.
Satu catatan soal throw: pesan exception bisa ikut terkirim ke klien. Jangan menaruh connection string, query mentah, atau isi variabel environment di pesan error — anggap saja semuanya terbaca pihak lain.
Bab 5: Resource dan Prompt: Dua Primitif yang Sering Dilewati
Resource: Data yang Dialamatkan
Setelah dua tool jadi, godaan berikutnya adalah menambah ambil_detail_produk sebagai tool ketiga. Tahan dulu — detail produk lebih tepat jadi resource, karena yang memutuskan kapan dibaca semestinya user atau klien, bukan model.
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
server.registerResource(
'produk',
new ResourceTemplate('produk://{id}', { list: undefined }),
{
title: 'Detail produk',
description: 'Detail lengkap satu produk',
mimeType: 'application/json',
},
async (uri, { id }) => {
const p = await ambilProduk(Number(id));
if (!p) throw new Error(`Produk ${id} tidak ditemukan`);
return {
contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(p) }],
};
},
);ResourceTemplate membuat pola URI dengan parameter. Klien membaca produk://2, SDK mencocokkan polanya, dan { id } sampai ke handler kamu. Hasilnya:
{"contents":[{"uri":"produk://1","mimeType":"application/json","text":"{\"id\":1,\"name\":\"Kopi Gayo 250g\",...}"}]}list: undefined berarti server tidak menyediakan daftar lengkap resource. Ini keputusan sadar: katalog produk bisa puluhan ribu baris, dan mengirim semuanya ke klien tiap kali sesi dibuka jelas bukan ide bagus. Klien tetap bisa membaca URI tertentu; dia hanya tidak bisa minta daftar semuanya. Kalau resource kamu memang sedikit dan tetap — misalnya beberapa dokumen kebijakan — barulah menyediakan list masuk akal.
Perhatikan juga di sini saya pakai throw, bukan isError. Resource dibaca atas permintaan klien yang sudah memegang URI spesifik, bukan hasil tebakan model — kalau URI-nya tidak valid, itu kesalahan pemanggilan yang memang pantas jadi error protokol.
Prompt: Alur Kerja yang User Pilih
Prompt biasanya muncul di klien sebagai perintah slash. User yang memilihnya secara sadar.
server.registerPrompt(
'stok_menipis',
{
title: 'Laporan stok menipis',
description: 'Minta model merangkum produk yang stoknya hampir habis',
argsSchema: { batas: z.string().default('5') },
},
({ batas }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Cari produk dengan stok di bawah ${batas} lalu buat ringkasan singkat apa yang perlu di-restock.`,
},
},
],
}),
);Perhatikan z.string().default('5') untuk argumen yang jelas-jelas berupa angka. Itu bukan kelalaian, dan jebakannya cukup halus untuk layak dibahas sendiri: argumen prompt di lapisan protokol selalu string.
Yang membuatnya menyusahkan, menulis z.number() di argsSchema tidak ditolak waktu register. Server kamu start tanpa keluhan apa pun, prompt-nya muncul normal di prompts/list, dan kerusakannya baru terasa ketika ada user yang benar-benar memanggilnya — dan saat itu kamu buntu di kedua arah:
args={"batas":5} -> MCP error -32603: expected "string" ... at params
args={"batas":"5"} -> MCP error -32602: Invalid arguments for prompt p:
Invalid input: expected number, received string
Kirim angka, lapisan protokol menolak karena skema prompts/get mensyaratkan argumen berupa string. Kirim string, validasi zod kamu sendiri yang menolak. Tidak ada nilai yang bisa lolos keduanya.
Jadi terima sebagai string, lalu konversi di dalam handler kalau memang perlu dihitung — seperti batas di atas yang langsung disisipkan ke teks. Dan karena kesalahan ini tidak terdeteksi saat startup, satu tes getPrompt per prompt di CI sangat sepadan.
Prompt juga tempat yang pas untuk menuliskan pengetahuan operasional yang tidak dimiliki model. Kalimat "stok di bawah 5" mencerminkan ambang yang berlaku di toko kamu. Menaruhnya di prompt berarti user tidak perlu mengingat dan mengetik ulang aturan itu tiap kali.
Bab 6: Tool yang Mengubah Data: Annotations dan Batas Kewenangan
Menandai yang Berbahaya
Sejauh ini semua tool kita hanya membaca. Begitu ada tool yang mengubah data, taruhannya berubah — model sesekali salah pilih, dan salah pilih pada tool yang menghapus produk jauh lebih mahal daripada salah pilih pada pencarian.
server.registerTool(
'nonaktifkan_produk',
{
title: 'Nonaktifkan produk',
description:
'Sembunyikan produk dari katalog (soft delete, bisa diaktifkan lagi lewat admin). ' +
'Wajib minta konfirmasi user sebelum memanggil tool ini.',
inputSchema: { produk_id: z.number().int().positive() },
annotations: { readOnlyHint: false, destructiveHint: true },
},
async ({ produk_id }) => {
const p = await nonaktifkanProduk(produk_id);
if (!p) {
return {
isError: true,
content: [
{ type: 'text', text: `Produk ${produk_id} tidak ditemukan atau sudah nonaktif.` },
],
};
}
return { content: [{ type: 'text', text: `Produk "${p.name}" dinonaktifkan.` }] };
},
);Dengan fungsi repo-nya:
export async function nonaktifkanProduk(id) {
const { rows } = await pool.query(
`UPDATE products
SET is_active = false, updated_at = NOW()
WHERE id = $1 AND is_active = true
RETURNING id, name`,
[id],
);
return rows[0] ?? null;
}WHERE ... AND is_active = true di situ membuat operasinya aman diulang. Panggilan kedua untuk produk yang sama tidak mengubah apa-apa dan RETURNING tidak mengembalikan baris, sehingga handler membalas isError — tepat seperti yang kita inginkan.
Annotations dan Batasnya
annotations terkirim apa adanya di tools/list:
[
[ 'cari_produk', { readOnlyHint: true } ],
[ 'cek_stok', { readOnlyHint: true } ],
[ 'nonaktifkan_produk', { readOnlyHint: false, destructiveHint: true } ]
]Klien yang baik memakai destructiveHint untuk memunculkan dialog konfirmasi sebelum menjalankan tool. Tapi perhatikan namanya: hint, bukan enforcement. Tidak ada di protokol yang memaksa klien menghormatinya, dan klien yang kamu tidak kendalikan bisa saja mengabaikannya sepenuhnya.
Artinya annotations memperbaiki pengalaman pemakaian, bukan menjadi kontrol keamanan kamu. Kontrol keamanan yang sesungguhnya tetap di tempat biasa: batasi hak akses database user yang dipakai server ini, pilih soft delete alih-alih DELETE sungguhan, catat tiap perubahan ke log audit, dan jangan sekali-kali mengekspos tool yang akibatnya tidak bisa kamu batalkan.
Saya sendiri memegang aturan sederhana untuk ini: kalau sebuah operasi tidak bisa dibatalkan lewat satu perintah admin, operasi itu tidak pantas jadi MCP tool. Pindahkan saja ke antrean yang perlu persetujuan manusia.
Bab 7: Transport HTTP: Saat Server Dipakai Lebih dari Satu Orang
Kenapa Pindah dari stdio
stdio bekerja baik selama servernya jalan di mesin yang sama dengan kliennya. Begitu tim support ingin memakainya dari laptop masing-masing, atau servernya perlu jalan di dalam infrastruktur yang sama dengan database production, stdio tidak lagi memadai.
Logika server tidak perlu diubah sedikit pun — hanya transportnya:
// src/http.js
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { timingSafeEqual } from 'node:crypto';
import { buatServer } from './server.js';
const TOKEN = process.env.MCP_TOKEN;
if (!TOKEN) throw new Error('MCP_TOKEN wajib diset');
function tokenValid(header = '') {
const a = Buffer.from(header);
const b = Buffer.from(`Bearer ${TOKEN}`);
return a.length === b.length && timingSafeEqual(a, b);
}
const app = createMcpExpressApp();
app.use('/mcp', (req, res, next) => {
if (!tokenValid(req.headers.authorization)) {
return res.status(401).json({ error: 'unauthorized' });
}
next();
});
// Stateless: server + transport baru tiap request, tidak ada sesi untuk dibersihkan.
app.post('/mcp', async (req, res) => {
const server = buatServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on('close', () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.all('/mcp', (_req, res) => res.status(405).json({ error: 'method not allowed' }));
const port = Number(process.env.PORT ?? 3333);
app.listen(port, '127.0.0.1', () => console.log(`toko-mcp HTTP di http://127.0.0.1:${port}/mcp`));Yang Perlu Diperhatikan di Potongan Ini
createMcpExpressApp() bukan sekadar express(). Helper ini memasang validasi header Host lebih dulu, baru kemudian express.json(). Urutannya disengaja: request dengan Host yang tidak diizinkan ditolak sebelum body-nya dibaca.
Proteksi ini menangkal DNS rebinding — serangan di mana situs jahat mengarahkan domainnya ke 127.0.0.1 supaya browser korban bisa menembak server lokal kamu. Saat host 127.0.0.1, localhost, atau ::1 (defaultnya yang pertama), proteksi ini aktif otomatis. Hasilnya terlihat jelas kalau dicoba:
curl -H 'Host: evil.example.com' -H 'Authorization: Bearer rahasia-dev' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-X POST localhost:3333/mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'{"jsonrpc":"2.0","error":{"code":-32000,"message":"Invalid Host: evil.example.com"},"id":null}Status 403, dan handler kamu tidak pernah jalan. Kalau kamu bind ke 0.0.0.0, proteksi otomatis ini tidak aktif — kamu harus menyebut sendiri allowedHosts yang sah.
sessionIdGenerator: undefined berarti stateless. Tiap request POST mendapat instance server dan transport yang baru, lalu keduanya ditutup saat koneksi selesai. Tidak ada state sesi yang menumpuk di memori, dan server bisa di-scale horizontal tanpa sticky session. Untuk server yang tool-nya tidak saling bergantung seperti ini, stateless hampir selalu pilihan yang benar.
timingSafeEqual, bukan ===. Perbandingan string biasa berhenti di karakter pertama yang berbeda, dan selisih waktunya — walau kecil — bisa dipakai menebak token karakter demi karakter. Pengecekan a.length === b.length sebelum timingSafeEqual juga wajib, karena fungsi itu melempar error kalau panjang kedua buffer berbeda.
app.all di akhir. Tanpa itu, request GET ke /mcp akan jatuh ke handler 404 default Express dengan HTML, yang membingungkan klien yang mengharapkan JSON.
Sedikit tentang bentuk responsnya: klien MCP mengirim Accept: application/json, text/event-stream, dan transport HTTP membalas dengan framing SSE:
event: message
data: {"result":{"content":[{"type":"text","text":"#2 Kopi Toraja 250g — Rp92.000, stok 3"}]},"jsonrpc":"2.0","id":2}
Payload JSON-RPC-nya identik dengan yang keluar di stdio — hanya pembungkusnya yang berbeda, karena transport ini juga perlu melayani respons yang mengalir bertahap.
Token Statis Cukup untuk Siapa?
Autentikasi bearer token di atas cukup untuk server internal yang dipakai tim kecil dengan token yang dirotasi berkala. Untuk server yang dipakai di luar kendali kamu, MCP punya spesifikasi OAuth 2.1 tersendiri dan SDK menyediakan dukungannya di server/auth — pembahasan yang terlalu panjang untuk dimuat di sini, tapi perlu kamu ketahui keberadaannya sebelum terlanjur menyebar token statis ke banyak orang.
Satu hal yang tidak boleh ditawar: app.listen(port, '127.0.0.1', ...). Selama belum ada autentikasi yang matang, jangan pernah bind MCP server ke alamat publik.
Bab 8: Menguji Server: Inspector dan Client Sendiri
MCP Inspector
Untuk pemeriksaan manual, SDK resmi punya alat inspeksi berbasis web:
npx @modelcontextprotocol/inspector node src/stdio.jsInspector menjalankan server kamu, membuka antarmuka di browser, dan menampilkan seluruh tool, resource, serta prompt lengkap dengan form untuk memanggilnya satu per satu. Alat ini yang paling cepat untuk menjawab "kenapa tool saya tidak muncul" — biasanya karena servernya gagal start, dan Inspector menunjukkan error-nya langsung alih-alih diam seperti kebanyakan klien.
Client SDK untuk Pengujian Otomatis
Inspector bagus untuk eksplorasi, tapi tidak bisa masuk CI. Untungnya SDK yang sama menyediakan sisi klien, dan menulis tes jadi sangat ringkas:
// test-stdio.mjs
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const client = new Client({ name: 'tes', version: '1.0.0' });
await client.connect(new StdioClientTransport({ command: 'node', args: ['src/stdio.js'] }));
console.log((await client.listTools()).tools.map((t) => t.name));
console.log(await client.callTool({ name: 'cek_stok', arguments: { produk_id: 2 } }));
console.log(await client.readResource({ uri: 'produk://1' }));
await client.close();Klien ini memulai server kamu sebagai proses anak, melakukan handshake, dan memanggil tool persis seperti klien sungguhan. Untuk transport HTTP, hanya transportnya yang ditukar:
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
await client.connect(
new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3333/mcp'), {
requestInit: { headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` } },
}),
);Yang saya sarankan ditempatkan di CI minimal ada tiga: setiap tool punya satu kasus sukses, setiap tool yang bisa gagal punya satu kasus isError, dan — untuk transport HTTP — satu kasus request tanpa token yang harus dijawab 401. Tiga pengujian itu menangkap hampir semua kerusakan yang pernah saya temui waktu menata ulang tool.
Dan ada satu pengujian yang tidak bisa diotomatiskan: baca ulang description tiap tool, lalu tanyakan pada diri sendiri apakah seseorang yang belum pernah melihat kode kamu bisa memilih tool yang tepat hanya dari kalimat itu. Kalau jawabannya ragu, model juga akan ragu.
Bab 9: Jebakan yang Baru Terasa Belakangan
Bagian ini isinya hal-hal yang tidak muncul di dokumentasi mana pun, dan baru ketahuan waktu menjalankan sendiri.
Node 18 Akan Gagal di Transport HTTP
Ini yang paling mahal waktunya kalau tidak tahu. package.json SDK menyatakan engines: { node: ">=18" }, jadi wajar kalau kamu menganggap Node 18 aman. Transport stdio memang berjalan normal di Node 18. Transport HTTP tidak:
{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error",
"data":"ReferenceError: crypto is not defined"},"id":null}
Penyebabnya spesifik: SDK memanggil crypto.randomUUID() dari global WebCrypto — di server/webStandardStreamableHttp.js — tanpa mengimpor node:crypto. Di Node 18, globalThis.crypto tidak tersedia di dalam ES Module:
v18.20.4: globalThis.crypto = undefined
v20.12.2: globalThis.crypto = object
Yang membuatnya menyesatkan, menjalankan node -e "typeof crypto" di Node 18 akan menjawab 'object' — tapi itu modul node:crypto yang memang diekspos sebagai global di konteks -e, bukan WebCrypto. Begitu kodenya jalan sebagai file .mjs sungguhan, nilainya undefined.
Pakai Node 20 ke atas. Kalau terpaksa di Node 18, jalankan dengan --experimental-global-webcrypto, tapi menaikkan versi Node jauh lebih murah daripada merawat flag itu.
stdout dan Mitos yang Separuh Benar
Nasihat "jangan console.log di server stdio" sering disampaikan seolah fatal. Saya coba buktikan dengan sengaja mencemari stdout, dan hasilnya ternyata lebih bernuansa.
Dengan satu baris console.log('Server dimulai...') sebelum connect, sesi tetap berjalan — klien tetap berhasil melihat ketiga tool. Yang terjadi, parse error-nya muncul di transport.onerror:
[transport.onerror] Unexpected token 'S', "Server dimulai..." is not valid JSON
hasil: 3 tools
Penyebabnya ada di processReadBuffer milik SDK klien: tiap baris diurai dalam blok try sendiri, jadi baris yang gagal dilewati dan perulangannya lanjut ke baris berikutnya.
Jadi tetap pakai console.error, tapi dengan alasan yang benar: bukan karena pasti mematikan server, melainkan karena kamu memancarkan error ke tiap klien yang terhubung, dan ketahanan itu milik implementasi klien tertentu — bukan jaminan protokol. Klien lain boleh saja memutus koneksi. Untuk logging sungguhan di server stdio, tulis ke file atau ke stderr.
Deskripsi Tool Ikut Memakan Context Window
Tiap tool yang kamu daftarkan — nama, deskripsi, dan seluruh skema input-nya — dikirim ke model di setiap percakapan. Server dengan 40 tool bisa memakan ribuan token sebelum user mengetik apa pun, dan di titik tertentu model justru makin sering salah pilih karena pilihannya terlalu banyak dan mirip-mirip.
Lebih baik punya delapan tool yang masing-masing jelas kegunaannya daripada empat puluh tool yang saling tumpang tindih. Kalau koleksinya memang harus besar, pecah jadi beberapa server menurut domain, biar user mengaktifkan yang dia perlukan saja.
Input Model Tetap Input yang Tidak Dipercaya
Argumen tool datang dari model, dan model bisa dipengaruhi isi percakapan — termasuk teks yang ditempelkan user dari sumber luar. Perlakukan argumen yang masuk persis seperti kamu memperlakukan request body dari internet: query berparameter (bukan string yang dirangkai), skema zod yang ketat, dan hak akses database yang seminimal mungkin.
Kalau sebuah MCP tool menerima string lalu menyisipkannya ke SQL, shell, atau pemanggilan HTTP ke host yang ditentukan pemanggil, kamu sedang membuka jalan masuk yang asalnya dari percakapan — bukan dari user kamu langsung.
Penutup
Bentuk akhir yang kita bangun:
Klien MCP (Claude Desktop / Claude Code / client sendiri)
│
├── stdio ──────▶ src/stdio.js ─┐
│ │
└── HTTP ───────▶ src/http.js ──┤──▶ buatServer() [src/server.js]
│ │ │
│ │ ├── tools: cari_produk, cek_stok, nonaktifkan_produk
│ │ ├── resource: produk://{id}
│ │ └── prompt: stok_menipis
│ │ │
auth + Host validation │ ▼
│ produk-repo.js ──▶ PostgreSQL (tabel products)
└── logika server identik di kedua transport
Checklist Sebelum Dipakai Orang Lain
Langkah Selanjutnya
-
Arsitektur AI Agent — MCP server yang baru kita buat menyediakan kapabilitas, tapi belum ada yang mengatur urutan pemanggilannya. Begitu sebuah agent perlu merangkai beberapa tool untuk satu tujuan, muncul persoalan baru: pengelolaan state, penanganan langkah yang gagal di tengah, dan batas berapa lama agent boleh terus mencoba.
-
Studi Kasus: Backend untuk Produk AI — menyatukan integrasi LLM, RAG, dan MCP jadi satu sistem yang utuh, dengan keputusan arsitektur yang menyertainya.
Kalau kamu ingin melatih apa yang ada di artikel ini, tambahkan satu tool baru ke toko-mcp — misalnya riwayat_harga — lalu pakai lewat Claude Desktop tanpa menyentuh konfigurasi klien sama sekali. Momen ketika tool itu muncul begitu saja di sana, cuma karena servernya di-restart, adalah cara tercepat memahami kenapa protokol ini dibuat.