Ringkasan Utama
MCP actions backend milik Backstage mengubah setiap action yang terdaftar menjadi tool MCP di /api/mcp-actions/v1, dan endpoint default itu selalu membuka semuanya. Amankan dengan memfilter action destructive dan write, memilih OAuth CIMD ketimbang static token bersama, serta mematikan tracing payload tool.
Saya mencari tahu bagaimana AI client seharusnya mengakses portal Backstage dan mengira isinya pembungkus tipis di atas API catalog. Yang dijelaskan dokumentasi ternyata berbeda: sebuah plugin yang mengambil apa pun yang didaftarkan plugin lain di Actions Registry lalu menerbitkannya ulang sebagai tool MCP, termasuk execute-template milik scaffolder.
Tulisan ini adalah pembacaan teliti dokumentasi MCP Actions Backstage, dokumentasi service Actions dan Actions Registry, changelog plugin, source action catalog dan scaffolder di branch master, serta spesifikasi authorization dan tools MCP. Saya tidak menjalankannya di portal production, jadi semua klaim di bawah adalah hal yang saya baca, dan bagian yang berupa dugaan saya tandai sebagai inference.
Apa sebenarnya MCP actions backend itu
Paketnya bernama @backstage/plugin-mcp-actions-backend. Paket ini tidak mendefinisikan tool sendiri. Plugin backend mendaftarkan action ke service Actions Registry, lalu plugin ini mendaftar dan mengeksposnya lewat Streamable HTTP di /api/mcp-actions/v1. Anda memilih plugin mana yang berkontribusi dengan backend.actions.pluginSources.
# From the repo root
yarn --cwd packages/backend add @backstage/plugin-mcp-actions-backend
// packages/backend/src/index.ts
const backend = createBackend();
// ...
backend.add(import('@backstage/plugin-mcp-actions-backend'));
// ...
backend.start();
# app-config.yaml -- only plugins listed here have their actions
# discovered and exposed as MCP tools.
backend:
actions:
pluginSources:
- 'catalog'
- 'my-custom-plugin'Ada dua hal sebelum membahas keamanan. Pertama, Actions Registry ditandai alpha di dokumentasi, dan import contohnya berasal dari @backstage/backend-plugin-api/alpha. Kedua, saat saya membaca branch master, package.json plugin ini bernilai 0.2.3-next.1, jadi dokumentasi yang saya kutip menggambarkan target yang bergerak, bukan rilis beku. Kunci versi Anda dan baca ulang changelog setiap upgrade.
Changelog juga mencatat satu breaking change penting: versi 0.2.0 menghapus transport Server-Sent Events yang sudah deprecated, sehingga client harus memakai endpoint Streamable HTTP atau endpoint named server. Konfigurasi client lama yang masih menunjuk URL SSE akan berhenti bekerja setelah upgrade itu.
Apa yang didapat AI client secara default
Hal paling berguna yang saya lakukan adalah membaca kode registrasi, bukan hanya narasinya. Tiap action mendeklarasikan atribut readOnly, destructive, dan idempotent. Tabel di bawah mendaftar semua action yang diregistrasi plugin catalog dan scaffolder di master, dengan nilai dari file source. ID action mengikuti pola pluginId:actionName, dan nama tool MCP mengganti titik dua dengan titik secara default, jadi catalog:get-catalog-entity menjadi catalog.get-catalog-entity.
| ID action | readOnly | destructive | idempotent |
|---|---|---|---|
| catalog:get-catalog-model-description | ya | tidak | ya |
| catalog:get-catalog-entity | ya | tidak | ya |
| catalog:query-catalog-entities | ya | tidak | ya |
| catalog:validate-entity | ya | tidak | ya |
| catalog:register-entity | tidak | tidak | tidak |
| catalog:refresh-catalog-entity | tidak | tidak | ya |
| catalog:unregister-entity | tidak | ya | ya |
| scaffolder:list-scaffolder-actions | ya | tidak | ya |
| scaffolder:list-scaffolder-tasks | ya | tidak | ya |
| scaffolder:get-scaffolder-task-logs | ya | tidak | ya |
| scaffolder:dry-run-template | ya | tidak | ya |
| scaffolder:execute-template | tidak | ya | tidak |
Perhatikan dua write yang tidak ditandai destructive. register-entity membuat Location yang menunjuk ke catalog-info.yaml remote, sehingga agent bisa membuat catalog mengimpor file apa pun yang disajikan sebuah URL. Action ini dideklarasikan destructive: false karena menambah, bukan menghapus. Itu pembacaan atribut yang wajar, tetapi artinya rule yang hanya mengecualikan destructive: true tetap meninggalkan register-entity. Ini interpretasi saya, bukan peringatan di dokumentasi.
Detail lainnya adalah execute-template. Deskripsinya menyebut template dijalankan dengan credentials yang diberikan ke action dan menghormati RBAC yang melekat padanya, serta menyuruh agent mengambil entity Template dulu untuk mengetahui skema parameter dan definisi secrets. Di source, secrets opsional untuk template menjadi bagian dari input action, bukan skema secrets terpisah, kebalikan dari rekomendasi dokumentasi registry untuk credentials.
Endpoint default membuka semuanya
Dokumentasinya tegas: server default di /api/mcp-actions/v1 selalu tersedia dan selalu mengekspos setiap action yang terdaftar, entah mcpActions.servers dikonfigurasi atau tidak. Named server adalah subset darinya. Changelog 0.2.2 menjelaskan perubahannya: dulu mengonfigurasi satu named server menggantikan endpoint default, sekarang keduanya ada berdampingan.
# app-config.yaml
mcpActions:
name: 'Acme Backstage'
servers:
catalog-readonly: # served at /api/mcp-actions/v1/catalog-readonly
name: 'Backstage Catalog (read-only)'
description: 'Look up services, owners and APIs in the software catalog'
instructions: 'Inspect catalog entities before suggesting changes.'
filter:
include:
- id: 'catalog:*'
exclude: # exclude rules beat include rules
- attributes:
destructive: true # drops catalog:unregister-entity
- id: 'catalog:register-entity'
- id: 'catalog:refresh-catalog-entity'
# Client side: point the AI client at the NAMED endpoint, not the default one.
{
"mcpServers": {
"backstage-catalog": {
"url": "https://backstage.example.com/api/mcp-actions/v1/catalog-readonly",
"headers": { "Authorization": "Bearer ${MCP_TOKEN}" }
}
}
}Artinya named server yang rapi adalah kemudahan untuk client, bukan batas keamanan. Siapa pun dengan token yang bisa memanggil endpoint default tetap bisa menjangkau semua yang diekspos plugin source. Gunakan named server untuk fokus, sesuai maksud dokumentasi, dan tempatkan penegakan di tempat lain.
Menambah named server read-only tidak menghilangkan endpoint default. Sebelum 0.2.2, menambahkannya justru menggantikan endpoint default, jadi konfigurasi yang ditulis untuk perilaku lama mungkin aman secara kebetulan dan tidak lagi aman setelah upgrade. Uji ulang tools/list ke URL default setiap upgrade.
Tegakkan di Actions Service dan layer permission
Dokumentasi memberi empat tempat untuk berkata tidak, dan masing-masing punya tugas berbeda. Tabel mendaftarnya beserta apa yang didokumentasikan bisa dicegah. Filter level service adalah yang berlaku untuk endpoint default, karena posisinya di bawah plugin MCP, bukan di dalam satu named server.
| Kontrol | Letaknya | Yang dicegah |
|---|---|---|
| pluginSources | backend.actions.pluginSources di app-config.yaml | Seluruh plugin yang berkontribusi action. Plugin yang tidak terdaftar tidak pernah ditemukan sebagai tool MCP. |
| Filter service | rule include dan exclude di backend.actions.filter | Action individual berdasarkan glob ID atau atribut, untuk semua consumer Actions Service termasuk endpoint MCP default. |
| Filter named server | mcpActions.servers.NAME.filter | Apa yang ditampilkan satu endpoint bernama. Rule exclude menang atas include. Tampilan subset, bukan batas. |
| visibilityPermission | Pada action, dicek oleh permissions framework | Visibilitas per pemanggil. Action yang ditolak hilang dari daftar dan menjawab 404 saat dipanggil. Action tanpa field ini tetap terlihat oleh semua orang. |
# app-config.yaml -- the service-level filter applies to the Actions Service
# itself, so it holds for every consumer, including the default MCP endpoint.
# Within one rule, id and attributes are ANDed; separate rules are ORed.
backend:
actions:
pluginSources:
- catalog
- scaffolder
filter:
exclude:
- attributes:
destructive: true # unregister-entity, execute-template
- id: 'catalog:register-entity' # declared destructive: false, still a writeSemantik filter didokumentasikan di halaman Actions: dalam satu rule, id dan attributes digabung dengan AND, dan rule terpisah dalam array include atau exclude yang sama digabung dengan OR. Halaman MCP hanya menyebut filternya mendukung glob pada ID dan pencocokan atribut, jadi saya akan mengasumsikan logika yang sama tetapi memverifikasinya dengan panggilan tools/list memakai token uji sebelum mempercayainya.
Autentikasi: static token, CIMD, atau DCR
Autentikasi wajib secara default. Dokumentasi menawarkan tiga jalur, dan ringkasan jujurnya hanya satu yang disajikan sebagai tujuan akhir. Static token diberi peringatan sebagai workaround sementara sampai device authentication selesai, dan DCR ditandai deprecated.
| Metode | Status dan syarat menurut dokumentasi | Trade-off yang saya lihat |
|---|---|---|
| Static token | Workaround sementara. Dikonfigurasi di backend.auth.externalAccess dengan token, subject, dan accessRestrictions. | Satu secret dan satu subject untuk semua pemanggil. Subject pada contoh dokumentasi adalah mcp-clients, jadi di log semua orang tampak sama (inference saya). |
| OAuth CIMD | Direkomendasikan. Butuh New Frontend System, auth-backend, dan frontend plugin @backstage/plugin-auth. Diaktifkan dengan auth.clientIdMetadataDocuments.enabled. | Tidak ada token yang ditempel di pengaturan client, dan user menyetujui lewat popup Backstage. Konsekuensinya migrasi frontend jika masih memakai sistem lama. |
| OAuth DCR | Deprecated. Masih berfungsi, mencatat peringatan deprecation saat startup. Spesifikasi MCP menurunkan DCR dari SHOULD menjadi MAY pada revisi November 2025. | Boleh untuk deployment yang sudah ada, salah untuk yang baru. Rencanakan pindah ke CIMD. |
# Static token -- the documented stopgap. Generate it, never hand-type it:
# node -p 'require("crypto").randomBytes(24).toString("base64")'
backend:
auth:
externalAccess:
- type: static
options:
token: ${MCP_TOKEN}
subject: mcp-clients # every caller shares this one identity
accessRestrictions:
- plugin: mcp-actions
- plugin: catalog
# CIMD -- the recommended path. Note the top-level "auth:" key.
auth:
clientIdMetadataDocuments:
enabled: true
# Setting this REPLACES the Claude / VS Code / ChatGPT Codex defaults.
# allowedClientIdPatterns:
# - 'https://claude.ai/*'
# Defaults to loopback addresses only.
# allowedRedirectUriPatterns:
# - 'http://localhost:*/*'Spesifikasi authorization MCP menjelaskan mengapa CIMD menarik dan di mana ia berisiko. Client memakai URL HTTPS sebagai client_id, dan authorization server mengambil dokumen metadata di URL itu, sehingga spesifikasi memperingatkan soal SSRF dan meminta server memvalidasi bahwa client_id dalam dokumen cocok dengan URL dan redirect URI cocok. Spesifikasi juga mengakui CIMD tidak bisa menghentikan impersonasi localhost sendirian: penyerang dapat menyodorkan URL metadata client sah sambil menerima code di port localhost, itulah sebabnya server diminta memberi peringatan untuk redirect URI yang hanya localhost.
Pengaturan di Backstage selaras dengan model ancaman itu. allowedClientIdPatterns membatasi URL client_id yang diterima, dan mengisinya menggantikan default Claude, VS Code, dan ChatGPT Codex sepenuhnya, jadi daftar kustom harus memuat setiap client yang masih Anda inginkan. allowedRedirectUriPatterns secara default hanya alamat loopback. Persempit keduanya ke client yang benar-benar dipakai engineer Anda.
Menulis action sendiri tanpa menyesal
Jika tim platform menambah action, registry memberi empat tuas keamanan dan contoh di bawah memakai semuanya: atribut yang jujur, visibilityPermission, skema secrets yang dipisah dari input, dan error bertipe dari @backstage/errors.
import { createPermission } from '@backstage/plugin-permission-common';
import { NotAllowedError, NotFoundError } from '@backstage/errors';
const restartPermission = createPermission({
name: 'my-plugin.actions.restartService',
attributes: { action: 'update' },
});
actionsRegistry.register({
// kebab-case verb; the plugin id is added for you (my-plugin:restart-service)
name: 'restart-service',
title: 'Restart Service',
description: 'Restarts one service from the catalog. Check the entity first.',
// BasicPermission only. Denied callers do not see the tool and get a 404.
visibilityPermission: restartPermission,
schema: {
input: z => z.object({ entityRef: z.string() }),
output: z => z.object({ restarted: z.boolean() }),
// Declared apart from input so it never lands in the tool definition
// or the model's context.
secrets: z =>
z.object({ deployToken: z.string().describe('Deploy API token') }),
},
// Defaults are conservative, but say it out loud: clients read these.
attributes: { destructive: true, idempotent: false, readOnly: false },
action: async ({ input, secrets, credentials, logger }) => {
logger.warn(`Restart requested for ${input.entityRef}`);
const svc = await lookupService(input.entityRef, credentials);
if (!svc) throw new NotFoundError(`No service ${input.entityRef}`);
if (!canRestart(credentials, svc)) {
throw new NotAllowedError('Not allowed to restart this service');
}
await restart(svc, secrets.deployToken);
return { output: { restarted: true } };
},
});Spesifikasi tools MCP menjelaskan mengapa atribut saja bukan kontrol. Spesifikasi menyebut client harus menganggap annotation tool tidak tepercaya kecuali berasal dari server tepercaya, dan harus selalu ada manusia dalam loop yang bisa menolak pemanggilan. Dokumentasi Backstage menggambarkan atribut sebagai informasi agar client bisa memperingatkan sebelum panggilan destructive atau melewati konfirmasi pada yang read-only. Atribut adalah petunjuk untuk client, jadi filter atau permission yang harus menegakkan.
Error layak satu baris. Error Backstage yang dikenal seperti InputError dan NotFoundError ditangkap dan dikembalikan sebagai hasil tool isError: true, sedangkan tipe error yang tidak dikenal bisa muncul sebagai 500 generik. Lempar error bertipe agar model melihat pesan yang bisa ditindaklanjuti, bukan kegagalan buram.
Di mana ini bisa salah
Ini jebakan yang saya temukan di sumber, masing-masing terikat pada baris di dokumentasi, changelog, atau kode, bukan pada insiden saya sendiri.
- Konfigurasi di key yang salah. auth.clientIdMetadataDocuments harus berada di key top-level auth. Jika ditaruh di backend.auth, client OAuth mendapat invalid_client.
- Client ID basi. VS Code bisa menyimpan cache client ID OAuth lama, dan dokumentasi menyuruh menjalankan Authentication: Remove Dynamic Authentication Providers lalu merestart server.
- Tracing payload dibiarkan menyala. mcpActions.tracing.capture.toolPayload merekam argumen dan hasil tool pada span, dan dokumentasi memperingatkan isinya bisa memuat payload entity dan input scaffolder.
- Action yang cacat hilang diam-diam. Sejak 0.1.14 plugin memvalidasi tiap action terhadap skema tool MCP pada tools/list, mencatat peringatan, dan menghilangkan action yang buruk. Tool baru Anda bisa tidak muncul tanpa error di client.
- Static token bersama. Satu token dan satu subject untuk semua engineer berarti trace hanya mengatakan sebuah service principal bertindak, bukan siapa yang meminta.
Benang merahnya: jalur aman butuh konfigurasi yang disengaja, sedangkan jalur mudah, static token ke endpoint default dengan semua plugin source terdaftar, adalah yang paling banyak membuka akses.
Yang dikerjakan hari Senin
Urutan yang akan saya ikuti, berurutan, di portal non-production dulu.
- Daftar backend.actions.pluginSources dan buang setiap plugin yang tidak bisa Anda pertanggungjawabkan. Mulai dari catalog saja.
- Tambahkan backend.actions.filter yang mengecualikan destructive: true dan action write yang tidak ditandai destructive, lalu panggil tools/list di endpoint default dan pastikan semuanya hilang.
- Pindah ke CIMD jika sudah memakai New Frontend System. Jika belum, pakai static token dengan accessRestrictions dan rencanakan migrasi, perlakukan token sebagai berumur pendek.
- Tambahkan visibilityPermission ke setiap action yang Anda tulis sendiri, dan set atribut secara eksplisit alih-alih mengandalkan default.
- Nyalakan tracing dan metrics, biarkan toolPayload mati, dan putuskan dengan sadar apakah mengaktifkan backend.tracing.capture.endUser.
# Observability that is safe by default. Both flags default to false.
backend:
tracing:
capture:
endUser: true # adds enduser.id to spans; backend-wide, all plugins
mcpActions:
tracing:
capture:
toolPayload: false # keep OFF: arguments and results may hold sensitive data
# Then in your tracing backend, filter tools/call spans by:
# backstage.plugin.id which plugin owned the action (catalog, scaffolder)
# backstage.principal.type user | service | none
# gen_ai.tool.name which tool
# and chart mcp.server.operation.duration per tool.Plugin mengeluarkan metrics mcp.server.operation.duration dan mcp.server.session.duration, dan rilis 0.2.1 menambah audit event untuk connection, tool-discovery, dan tool-execution lewat Backstage Auditor Service. Itulah yang memungkinkan Anda menjawab siapa memanggil tool apa, pertanyaan yang tidak bisa dijawab token bersama.
Aturan yang saya bawa dari sini: pada MCP actions backend Backstage, named server adalah tampilan dan filter Actions Service adalah pagarnya. Ekspos plugin sesedikit mungkin, kecualikan write di level service, autentikasi per user dengan CIMD, dan perlakukan atribut action sebagai petunjuk untuk client, bukan perlindungan.
Sumber dan bacaan lanjutan
- Dokumentasi Backstage: MCP Actions Backend
- Dokumentasi Backstage: Actions Registry (alpha)
- Dokumentasi Backstage: service Actions dan filtering
- GitHub: CHANGELOG mcp-actions-backend
- GitHub: source action execute-template scaffolder
- GitHub: source action unregister-entity catalog
- Spesifikasi MCP 2025-11-25: Authorization
- Spesifikasi MCP 2025-06-18: Tools

