AI
OpenAI Responses API Remote MCP: Approval, Filter, Tunnel
Oktober 202612 menit baca

Tambahkan tool bertipe mcp ke array tools dengan server_label, lalu isi server_url untuk server publik atau tunnel_id untuk server privat di balik Secure MCP Tunnel. OpenAI kemudian mencatat daftar tool server sebagai item mcp_list_tools dan setiap pemanggilan sebagai item mcp_call. Isi token OAuth di field authorization kalau server membutuhkannya.
Ya. Secara default OpenAI meminta approval sebelum data apa pun dibagikan ke remote MCP server, dan pemanggilannya kembali sebagai item mcp_approval_request. Anda menjawabnya dengan item mcp_approval_response di request baru yang memakai previous_response_id. Atur require_approval ke never, atau ke filter never dengan tool_names, hanya untuk tool yang Anda percaya.
allowed_tools menentukan tool mana dari server yang bisa dilihat model, jadi tool yang tidak ada di daftar tidak pernah bisa dipanggil dan tidak memakan token. require_approval menentukan tool mana yang boleh berjalan tanpa persetujuan dari kode Anda di setiap pemanggilan. Pakai keduanya: daftar allowed_tools yang pendek, dan approval untuk setiap tool yang menulis data.
Responses API sengaja tidak menyimpan nilai authorization dan tidak menampilkannya di objek Response, sehingga response yang tersimpan tidak pernah bisa membocorkan token. Artinya setiap pemanggilan create, termasuk setiap pemanggilan lanjutan di loop approval, harus menyertakan token lagi. Refresh token berumur pendek di antara putaran, karena approval oleh manusia bisa lebih lama dari umur token.
Bisa, lewat Secure MCP Tunnel. Anda membuat tunnel di Platform tunnel settings, menjalankan tunnel-client di dalam jaringan dengan HTTPS keluar ke api.openai.com port 443, lalu mengisi tunnel_id di tool mcp sebagai pengganti server_url. Tidak ada port masuk yang dibuka, tetapi authorization server OAuth tidak ikut di-tunnel dan tetap harus bisa dijangkau.

Ringkasan Utama
Tool remote MCP di OpenAI Responses API membuat OpenAI memanggil MCP server Anda atas nama model. Arahkan ke server publik dengan server_url atau server privat dengan tunnel_id, daftarkan hanya tool yang dibutuhkan di allowed_tools, wajibkan approval untuk setiap operasi tulis, dan kirim ulang token OAuth di setiap request karena API tidak pernah menyimpannya.
Pertama kali saya mengarahkan Responses API ke MCP server ERP internal, model bisa melihat semua tool yang diekspos server itu, termasuk tool yang menulis penyesuaian stok. Belum ada yang salah, tetapi juga tidak ada yang mencegahnya. MCP server itu dibuat untuk client desktop, tempat seseorang menekan Allow di setiap pemanggilan. Di Responses API, orang itu adalah kode Anda, dan kalau tool-nya dikonfigurasi longgar, tidak ada yang menekan apa pun.
Tulisan ini membahas sisi client dari tool remote MCP di OpenAI Responses API: bagaimana OpenAI menjangkau server Anda (server_url, Secure MCP Tunnel, dan connector_id yang sudah deprecated), bagaimana allowed_tools dan require_approval mempersempit apa yang bisa dilakukan model, cara menjalankan loop mcp_approval_request di TypeScript, dan kenapa token authorization harus ikut di setiap pemanggilan. Semua nama field dan perilakunya diambil dari panduan MCP OpenAI, panduan tunnel, dan definisi tipe di Python SDK resmi. Membangun server-nya sendiri adalah topik lain, yang sudah dibahas di tulisan MCP server sebelumnya di blog ini.
Anda menambahkan tool bertipe mcp ke array tools. Saat model memutuskan butuh server itu, API memanggil daftar tool milik server dan mencatat hasilnya sebagai output item mcp_list_tools. Saat model memanggil sebuah tool, infrastruktur OpenAI, bukan proses Anda, yang mengirim request ke MCP server lalu mencatat argumen dan output-nya di item mcp_call. Kalau approval diwajibkan, pemanggilan itu ditahan dan yang muncul adalah item mcp_approval_request. Berikut field yang mengatur perilaku tersebut:
| Field | Fungsinya | Yang perlu diperhatikan |
|---|---|---|
| server_label | Nama wajib untuk server, muncul lagi di setiap output item MCP | Jadikan kunci untuk audit log dan aturan approval |
| server_url / tunnel_id / connector_id | Cara OpenAI menjangkau server. Salah satu dari ketiganya wajib diisi | connector_id deprecated untuk model yang rilis setelah 1 September 2026 |
| authorization | Access token OAuth yang dikirim ke server | Tidak disimpan dan tidak dikembalikan di Response. Kirim setiap kali |
| headers | Map opsional berisi HTTP header tambahan untuk server | Janji tidak-disimpan didokumentasikan untuk authorization, jadi taruh token di sana |
| allowed_tools | Array nama tool, atau objek filter berisi tool_names dan read_only | read_only memercayai anotasi readOnlyHint milik server sendiri |
| require_approval | String always atau never, atau objek filter dengan key always dan never | Kalau tidak diisi, setiap pemanggilan butuh approval |
| defer_loading | Bersama tool search, memuat definisi tool hanya saat model membutuhkannya | Berguna untuk server dengan puluhan tool |
Menurut panduannya, API ini bekerja dengan server yang memakai Streamable HTTP atau transport lama HTTP dengan SSE. Tidak ada biaya per pemanggilan. Anda membayar token untuk mengimpor definisi tool dan untuk pemanggilan tool itu sendiri, dan karena itulah ukuran daftar tool lebih berpengaruh daripada yang biasanya diperkirakan.
Hal kunci yang perlu dipahami tentang tool ini adalah dari mana panggilan jaringannya berasal. Dengan function calling, backend Anda yang menjalankan tool. Dengan tool MCP, OpenAI yang terhubung ke server, jadi server harus bisa dijangkau dari OpenAI, bukan hanya dari aplikasi Anda. Ada tiga pilihan:
Deprecation connector_id ini akan merusak kode tanpa banyak tanda. Request yang berjalan di gpt-5.2, model yang masih dipakai di contoh legacy connector OpenAI, tidak dijamin tetap berjalan saat string model diganti ke versi yang lebih baru. Kalau ada connector_id di codebase Anda, migrasinya adalah mencari remote MCP server resmi untuk layanan itu lalu beralih ke server_url, atau menaruh server Anda sendiri di balik tunnel. Kerjakan sebelum upgrade model, jangan digabung dalam satu perubahan.
Sebagian besar MCP server ERP yang akan saya hubungkan tidak seharusnya ada di internet publik. Secure MCP Tunnel menghindari hal itu. Anda membuat tunnel di Platform tunnel settings, menjalankan tunnel-client di host yang sudah bisa menjangkau server, lalu client itu melakukan long-poll ke OpenAI untuk request MCP yang mengantre, meneruskan setiap request JSON-RPC secara lokal, dan mengirim balik response-nya. Yang dibutuhkan hanya HTTPS keluar ke api.openai.com port 443, atau mtls.api.openai.com kalau control-plane mTLS dikonfigurasi, tanpa port masuk sama sekali.
# Run this inside the network that can already reach the ERP's MCP server.
# It only needs OUTBOUND HTTPS to api.openai.com:443. No inbound port opens.
export CONTROL_PLANE_API_KEY="sk-..." # runtime API key for tunnel-client
tunnel-client init \
--sample sample_mcp_stdio_local \
--profile erp-stdio \
--tunnel-id tunnel_0123456789abcdef0123456789abcdef \
--mcp-command "python /opt/erp-mcp/server.py"
# An HTTP server takes --mcp-server-url https://mcp.internal.example.com/mcp
# instead of --mcp-command.
tunnel-client doctor --profile erp-stdio --explain
tunnel-client run --profile erp-stdio
# Keep "run" alive under systemd or as a sidecar: while it is down, every
# tool call routed through the tunnel fails.
# Responses API side: tunnel_id REPLACES server_url.
# Never paste the OpenAI-hosted tunnel endpoint into server_url.
{
"type": "mcp",
"server_label": "erp",
"tunnel_id": "tunnel_0123456789abcdef0123456789abcdef"
}Sebelum ini berjalan, ada dua hal yang harus siap. Pertama, permission: membuat tunnel butuh Tunnels Read dan Manage, sedangkan menjalankan tunnel-client atau memakai tunnel butuh Tunnels Read dan Use. Permission ini diatur di level organisasi, bukan project, dan panduannya mengingatkan bahwa role baru bisa butuh sampai 30 menit untuk aktif. Kedua, OAuth: metadata discovery lewat tunnel, tetapi authorization server-nya sendiri tidak ikut di-tunnel. Kalau identity provider Anda hanya bisa dijangkau dari jaringan internal, alur OAuth bisa gagal meskipun MCP server-nya terjangkau. Panduannya menyarankan menjalankan client sebagai sidecar Kubernetes di samping server, sebagai deployment terpisah, atau sebagai service systemd di VM.
tunnel-client menyediakan /healthz, /readyz, dan /metrics, ditambah admin UI lokal di /ui yang secara default hanya mendengarkan di loopback. Arahkan monitoring yang sudah ada ke /readyz. Selama client terputus, setiap pemanggilan tool lewat tunnel akan gagal, jadi perlakukan seperti dependency production lain, lengkap dengan alert.
allowed_tools adalah kontrol paling efektif di tool ini, dan mengaturnya tidak memakan biaya apa pun. Tool yang tidak ada di daftar tidak pernah diimpor, jadi model tidak bisa memanggilnya, tidak bisa diakali untuk memanggilnya, dan Anda tidak membayar token untuk definisinya. Panduan OpenAI mencatat bahwa mengekspos banyak tool menambah biaya dan latency. Aturan saya sederhana: tulis nama tool satu per satu, yang read-only lebih dulu, dan tambahkan tool tulis hanya kalau ada fitur nyata yang membutuhkannya.
Bentuk filter, yaitu objek berisi tool_names dan read_only, memang praktis, tetapi read_only bekerja dengan mencocokkan anotasi readOnlyHint milik server. Spesifikasi MCP menyatakan client wajib menganggap anotasi tool tidak tepercaya kecuali berasal dari server tepercaya. Server pihak ketiga yang melabeli tool hapus sebagai read-only akan lolos dari filter itu. Untuk server yang bukan buatan Anda, pakai nama eksplisit. Untuk server sendiri, read_only aman karena Anda mengendalikan kode dan anotasinya.
Untuk server dengan daftar tool panjang, defer_loading bernilai true adalah pilihan lain. Opsi ini bekerja bersama tool search: model melihat label dan deskripsi server, lalu memuat definisi fungsi satu per satu hanya saat memutuskan untuk mencari di server itu. Ini menghemat token, tetapi tidak membatasi apa pun. Tool yang di-defer tetap bisa dipanggil, jadi defer_loading adalah cara menekan biaya, bukan kontrol keamanan, dan Anda tetap butuh allowed_tools.
Secara default, OpenAI meminta approval sebelum data apa pun dibagikan ke remote MCP server, jadi tidak mengisi require_approval memberi perilaku paling aman. String never melewati approval untuk semua tool di server itu. Bentuk objek menerima key never, key always, atau keduanya, dan masing-masing berisi tool_names serta read_only yang opsional. Contoh dari OpenAI sendiri hanya melewati approval untuk tool baca yang disebut namanya. Berikut builder yang saya pakai, supaya kebijakannya dihitung di satu tempat dan tidak bergeser:
import type OpenAI from "openai";
type McpTool = OpenAI.Responses.Tool.Mcp;
// Tools I have read the server code for and know only run SELECTs.
const READ_ONLY = ["get_stock_level", "get_sales_order", "list_open_invoices"];
// The one write the model may propose. A person approves every call.
const WRITES = ["draft_stock_adjustment"];
// Wrong: if READ_ONLY ever ends up empty (a typo, a feature flag), a
// community bug report says an empty "never" list produced NO approval
// requests at all, the opposite of what the object appears to say.
const wrongPolicy: McpTool["require_approval"] = {
never: { tool_names: READ_ONLY },
};
// Right: fall back to the string form, which has only one meaning.
function approvalPolicy(skip: string[]): McpTool["require_approval"] {
return skip.length > 0 ? { never: { tool_names: skip } } : "always";
}
export function erpMcpTool(accessToken: string): McpTool {
return {
type: "mcp",
server_label: "erp",
server_description:
"Stock levels, sales orders and open invoices for the Jakarta warehouse.",
server_url: "https://mcp.erp.example.co.id/mcp",
// Not stored by the API and not echoed in the Response object,
// so it has to be sent again on every single create call.
authorization: accessToken,
// Anything else the server exposes is never shown to the model.
allowed_tools: [...READ_ONLY, ...WRITES],
require_approval: approvalPolicy(READ_ONLY),
};
}Model melihat empat tool dan bisa menjalankan tiga di antaranya tanpa menunggu. Tool keempat, yang menulis, selalu kembali ke kode saya sebagai approval request. Digabung dengan allowed_tools, ini memberi dua batas terpisah: apa yang diketahui model ada, dan apa yang bisa dijalankannya tanpa campur tangan orang. Bagian risiko di panduan OpenAI menyarankan persis hal ini: pakai require_approval dan allowed_tools bersamaan supaya setiap aksi sensitif melewati alur approval.
Sebuah laporan bug di forum developer OpenAI pada Desember 2025 menjelaskan require_approval berisi filter never dengan array tool_names kosong, yang ternyata tidak menghasilkan approval request sama sekali, dan menyebut bahwa menggabungkan never dan always juga berperilaku tidak sesuai harapan. Tidak ada balasan dari OpenAI di thread itu. Saya tidak bisa memastikan apakah sudah diperbaiki, jadi saya tidak pernah mengirim daftar kosong: kalau tidak ada tool yang boleh melewati approval, kirim string always.
Approval request bukan error dan bukan callback. Ia adalah output item berisi id, server_label, nama tool, dan argumen dalam bentuk string JSON. Untuk menjawabnya, Anda membuat response baru yang merujuk response lama lewat previous_response_id dan mengirim item mcp_approval_response dengan approve bernilai true atau false. Tipe di SDK juga punya field reason yang opsional. Ini loop-nya, lengkap dengan audit logging yang tidak akan saya lewatkan saat rilis:
import OpenAI from "openai";
import { erpMcpTool } from "./erp-mcp-tool";
// tokens, audit and reviewer are your own modules: an OAuth token store,
// an append-only log, and whatever decides (a person, or a rule you wrote).
import { tokens, audit, reviewer } from "./erp-agent-deps";
const client = new OpenAI();
const MODEL = "gpt-6-astra";
const MAX_APPROVAL_ROUNDS = 3;
export async function askErp(userId: string, question: string) {
let response = await client.responses.create({
model: MODEL,
tools: [erpMcpTool(await tokens.forUser(userId))],
input: question,
});
for (let round = 0; round < MAX_APPROVAL_ROUNDS; round++) {
const decisions: OpenAI.Responses.ResponseInputItem.McpApprovalResponse[] = [];
for (const item of response.output) {
if (item.type !== "mcp_approval_request") continue;
// item.arguments is the exact JSON the MCP server will receive.
// Approve the payload, not the tool name.
const args = JSON.parse(item.arguments);
await audit.log({ userId, server: item.server_label, tool: item.name, args });
const approve = await reviewer.decide(item.name, args);
decisions.push({ type: "mcp_approval_response", approval_request_id: item.id, approve });
}
if (decisions.length === 0) break; // nothing pending: the answer is final
response = await client.responses.create({
model: MODEL,
previous_response_id: response.id,
// The token from the first call is gone. Resend it, refreshed if needed.
tools: [erpMcpTool(await tokens.forUser(userId))],
input: decisions,
});
}
// A failed call does not throw: it lands in the item's error field.
for (const item of response.output) {
if (item.type === "mcp_call" && item.error) {
await audit.log({ userId, tool: item.name, error: item.error });
}
}
return response.output_text;
}Setiap bagian loop itu punya alasan:
Panduannya tegas: Responses API tidak menyimpan nilai authorization, nilainya tidak terlihat di objek Response, dan Anda harus mengirimnya di setiap request create. Ini disengaja, karena token yang tidak pernah disimpan tidak bisa bocor dari response yang tersimpan. Dalam praktiknya, setiap pemanggilan lanjutan di loop approval butuh token yang masih valid, jadi token store Anda harus me-refresh-nya di antara putaran. Orang yang menyetujui operasi tulis bisa dengan mudah butuh waktu lebih lama daripada umur token yang pendek.
Pakai token OAuth milik pengguna akhir, bukan satu service account untuk semua orang. Dengan begitu MCP server menegakkan permission pengguna tersebut, sehingga pemanggilan yang sudah disetujui pun tidak bisa membaca cabang atau perusahaan yang tidak bisa dibuka pengguna itu di ERP. Approval adalah kontrol Anda, dan scope token adalah kontrol server. Anda butuh keduanya.
Field headers, map opsional berisi HTTP header yang menurut tipe di SDK ditujukan untuk autentikasi atau keperluan lain, cocok untuk server yang mengharapkan sesuatu selain bearer token, misalnya tenant id atau API key di header khusus. Saya memakainya hanya untuk nilai routing yang tidak rahasia. Jaminan tidak-disimpan di panduan OpenAI ditulis untuk field authorization, dan saya lebih memilih tidak berasumsi jaminan itu juga berlaku untuk headers.
Pertahankan item mcp_list_tools di dalam context. Menurut panduannya, selama item itu ada, API tidak mengambil ulang daftar tool dari server di setiap giliran, dan OpenAI menyarankan menyimpannya di setiap percakapan untuk menekan latency. previous_response_id menangani ini secara otomatis. Kalau Anda mengelola context sendiri, misalnya dengan store bernilai false, Anda harus mengirim balik item itu sebagai input, atau Anda membayar latency dan token impor lagi di setiap giliran.
Soal data: dengan store bernilai true, data yang dikirim ke MCP server sudah dicatat oleh API selama 30 hari kecuali Zero Data Retention aktif, dan panduannya tetap menyarankan Anda menyimpan log sendiri. Tool MCP kompatibel dengan Zero Data Retention dan data residency, tetapi jaminan itu berhenti di titik MCP server dimulai. Data yang dikirim ke server mengikuti kebijakan retensi dan residency milik server itu. Untuk perusahaan Indonesia dengan kewajiban lokasi data, setup tunnel membantu di sini, karena server dan database-nya tetap di infrastruktur Anda sendiri. Perlakukan juga URL yang dikembalikan di output mcp_call sebagai tidak tepercaya. Panduannya mengingatkan agar tidak mengambil atau menyematkan URL itu kecuali domain-nya tepercaya, karena me-request URL pilihan penyerang sudah merupakan cara membocorkan data.
Aturan yang saya pegang sekarang: tool MCP harus dikonfigurasi dengan tingkat kepercayaan serendah mungkin yang masih berfungsi. Pakai nama eksplisit di allowed_tools, pakai filter never hanya untuk tool yang kodenya sudah saya baca, pakai string always alih-alih daftar kosong, setujui argumennya, bukan nama tool-nya, dan kirim token baru yang di-scope ke pengguna di setiap pemanggilan. Tunnel menjaga server privat tetap privat. Sisanya adalah kode yang harus ditulis dengan sengaja.