はじめに
自作の ServiceNow MCP サーバー(tedorigawa001/ServiceNow-MCP、npm 公開中)には、Table API(/api/now/table/...)だけで実装されたツールが 450 本以上あります。
そのうち Security Operations まわりの 3 本 ― SIR Playbook の起動、Vulnerability Response のスキャン起動、Background Script の実行 ― を実機(PDI)で検証し直したところ、どれも Table API では仕様的に実装できないことがわかりました。
この記事では、本検証で観測した Table API の挙動と、最終的に採用した「run-once の Scheduled Script Execution + syslog」方式の話をまとめたものです。
すべて 2026 年 9 月時点の PDI(Australia、glide.war = glide-australia-02-11-2026__patch3)での観測結果で、他バージョン・他インスタンスでは異なる可能性があります。
公式ドキュメントで裏付けが取れた箇所はリンクを付け、取れなかった箇所は「観測」と明記しています。
1. 200 OK なのに値が入っていない
観測 1: state を更新しても draft のまま
Vulnerability Response の手動スキャンは、製品コード(sn_vul.VulnerabilityScanUtil.createScanFromTask)を読むとこういう流れです。
-
sn_vul_scanを insert - 対象を m2m テーブル(
sn_vul_m2m_scan_configuration_itemなど)でリンク -
stateをprocessingに更新 → async の Business Rule「Process scan request」がスキャナ統合へ渡す
これを REST で再現しようとして、まず state の PATCH を投げました。
PATCH /api/now/table/sn_vul_scan/<sys_id>
{"state": "processing"}
→ 200 OK ... レスポンスの state は "draft"
エラーは返らず、sys_mod_count も増えません。
一方、同じレコードの ip フィールドは同じ PATCH で普通に書けます。
原因は辞書でした。sn_vul_scan の親テーブル sn_sec_cmn_scan で state が read_only = true、そして read_only_option = client_script_modifiable になっています(sn_vul_scan 側の dictionary override は read_only_override = false なので親の設定が効く)。
この Read only option は公式ドキュメント Make a field read only に 3 段階で定義されていて、Table API が名指しで挙げられています。
| Read only option | 公式の説明(要約) |
|---|---|
| Display Read Only | UI では読み取り専用だが、クライアントスクリプト・TableAPI・GraphQL・GlideRecordSecure() からは変更できる |
| Client Script Modifiable | クライアントスクリプトからは変更できるが、background script や TableAPI・GraphQL・GlideRecordSecure() のようなサーバ側 API からは変更できない |
| Strict Read Only | クライアント・サーバどちらからも変更できない |
つまり state が REST から動かないのは仕様どおりです。
エラーを返さず 200 で黙って捨てる、という点だけが罠です。
一方で、製品の「Initiate Scan」UI アクションは current.state = "processing"; current.update(); というサーバスクリプトで、これは通ります。私が run-once ジョブから素の GlideRecord で setValue('state', 'canceled') したときも通りました。公式の一覧に挙がっているのは GlideRecordSecure() で、素の GlideRecord は挙がっていません。
「素の GlideRecord は Client Script Modifiable を無視して書ける」は観測結果で、公式に明文化されたものは見つけられませんでした。
観測 2: m2m の参照フィールドが空で insert される
次に m2m のリンク行を POST しました。
POST /api/now/table/sn_vul_m2m_scan_configuration_item
{"sn_vul_scan": "<scan sys_id>", "cmdb_ci": "<ci sys_id>"}
→ 201 Created ... レスポンスの sn_vul_scan も cmdb_ci も ""
こちらは行は作られるのに、指定した 2 つの参照が両方とも空です。ACL を調べると、このテーブルの write ACL(テーブルレベル、admin_overrides = false)に条件 sn_vul_scan.state=new が付いていました。
「フィールドの write が許可されないと、そのフィールドだけ黙って落ちる」のか確かめるため、サーバ側で GlideRecordSecure(ACL を評価する方の GlideRecord)を使って同じ insert を試しました。実行ユーザーは admin / sn_vul.admin / sn_vul.write_all を持つ REST 用ユーザーです。
var m = new GlideRecordSecure('sn_vul_m2m_scan_configuration_item');
m.initialize();
m.setValue('sn_vul_scan', scanSysId); // draft のスキャン
m.setValue('cmdb_ci', ciSysId);
// canCreate()=true, canWrite()=false,
// sn_vul_scan.canWrite()=false, cmdb_ci.canWrite()=false
var id = m.insert(); // 成功する
// 読み戻すと sn_vul_scan も cmdb_ci も空
レコードの canCreate() は true、フィールドの canWrite() は false、insert は成功、参照は空 ― REST とまったく同じ結果になりました。admin でも同じです(admin_overrides = false なので)。
「ではスキャンを先に new にしておけば」と思って試すと、state = new で insert した瞬間に before Business Rule「Queue the scan」が queued に進めるため、state=new の条件は API 経由では誰にも満たせません。この m2m は製品のサーバスクリプト(素の GlideRecord、ACL 評価なし)が書くことだけを想定した設計になっている、というのが実態です。
コミュニティでは「insert 時に効くのは write ではなく create の ACL」という説明が一般的です(例)。今回はフィールドレベルの create ACL が存在せず、
canCreate()もフィールドでは false だったので、どの ACL が最終的に効いたかまでは切り分けられていません。確かなのは「フィールドの書き込み権限がないと、レコードは作られ、そのフィールドだけ空になり、エラーは返らない」ことです。
観測 3: 存在しないカラムは黙って無視される
これは既知の挙動ですが、今回も 2 か所で踏みました。
-
sysauto_scriptにdescriptionを渡していたが、そんなカラムは存在しない(sysautoの列はname,run_type,run_start,script, … だけ) - Agile の
rm_epicにprojectを渡していたが、rm_epicにprojectはなくrm_projectテーブルも存在しない(グルーピングはproduct/theme/parent_epic)
どちらも 201 が返っていたので、ユニットテストのモックが「同じ間違い」をしている限り永遠に気づけません。実機で読み戻して初めてわかる種類のバグです。
Table API が read-only フィールドの更新を 200 で黙って無視する挙動は、コミュニティでも同じ報告があります。
PUT Table API to update read only field ― "The status code returned is 200 so I thought it was successful. Though upon reviewing the record, the value didn't change."
教訓
Table API の書き込みは「HTTP ステータスが 2xx = 全部書けた」ではありません。
少なくとも次の 3 つは沈黙します。
| 沈黙するケース | 見え方 |
|---|---|
辞書 Read only option が Client Script Modifiable / Strict のフィールド |
200、値は旧値のまま、sys_mod_count 不変(公式仕様) |
| フィールドの書き込みが ACL で許可されない | 201/200、そのフィールドだけ空(GlideRecordSecure でも同じ) |
| 存在しないカラム | 201/200、レスポンスにそのキーがない |
対策は一つで、書いた直後に読み戻して比較すること。MCP 側のツールは、対象の存在確認・書き戻し値の返却・E2E での実機読み戻しを標準にしました。
2. RESTからサーバスクリプトを走らせる唯一の道
観測 1・2 の結論は「スキャンの作成はサーバ側スクリプトでしかできない」です。
ところが ServiceNow には サーバスクリプトを同期実行する REST エンドポイントがありません。
-
sys.scripts.do(Background Script 画面、公式: Scripts - Background module)は UI ページで、セッション Cookie と CSRF トークン(sysparm_ck)が前提 - 自作 MCP の
execute_background_scriptは/api/now/sp/background_scriptを叩いていましたが、これは存在しないエンドポイントでした(sys_ws_definitionを検索しても該当なし。最初から動いていなかったことになります) - 正攻法は Scripted REST API を自分でインスタンスに作ることです。ただし MCP サーバーは「インスタンス側に何もデプロイしない」のが前提なので、この選択肢は取れません
残る道は run-once の Scheduled Script Execution(sysauto_script) です。REST で作成でき、run_start を過去にしておけばスケジューラが数秒〜十数秒で拾います。製品自身も同種の run-once ジョブを各所で使っています。
戻り値をどう受け取るか
ジョブは非同期なので、結果を返す経路が要ります。試した順に:
-
ジョブ自身の
descriptionに書き戻す → 前述のとおりカラムが存在しない。setValueは no-op でupdate()は成功扱い -
syslogにgs.info()で書く → 5 秒以内に REST で読める。採用
ジョブ名は sysauto.name の上限 100 文字(辞書で確認)に収まるようにします(最初に Playbook のスコープ名から組み立てた名前が切り詰められ、完全一致検索が永遠にヒットしないバグを踏みました)。トークンは 32 桁の hex にして、スクリプトリテラルにもエンコード済みクエリにもそのまま埋め込めるようにしています。
// 共通ヘルパー(src/utils/script-job.ts)の骨子
export function newJobToken(prefix: string) {
return `${prefix}-${randomBytes(16).toString('hex')}`;
}
export async function scheduleScriptJob(client, name: string, script: string) {
const run_start = new Date(Date.now() - 60_000).toISOString().slice(0, 19).replace('T', ' ');
const job = await client.createRecord('sysauto_script', {
name, active: true, run_type: 'once', run_start, script,
});
return { sys_id: job.sys_id, name, run_start_utc: run_start };
}
export async function awaitScriptJobResult(client, token: string, waitSeconds: number) {
const deadline = Date.now() + waitSeconds * 1000;
for (;;) {
const logs = await client.queryRecords({
table: 'syslog', query: `messageSTARTSWITH${token} RESULT:`, fields: 'message', limit: 1,
});
const message = logs.records[0]?.message ?? '';
if (message) return JSON.parse(message.slice(message.indexOf('RESULT:') + 7));
if (Date.now() >= deadline) return undefined; // 呼び出し側は「scheduled」として返す
await new Promise(r => setTimeout(r, 3000));
}
}
ジョブ側はユーザースクリプトを関数でラップし、return 値または例外を JSON にして gs.info(token + ' RESULT:' + json) します。syslog.message は 4000 文字、sysauto_script.script は 8000 文字(いずれも辞書で確認)なので、結果は 3.5 KB、ユーザースクリプトは 6000 文字で切ります。
var __out;
try {
var __result = (function() {
/* ユーザースクリプト。return で値を返せる */
})();
__out = { ok: true, result: __result === undefined ? null : __result };
} catch (__e) {
__out = { ok: false, error: String(__e.message || __e), stack: String(__e.stack || '').slice(0, 800) };
}
gs.info(__token + ' RESULT:' + JSON.stringify(__out));
実機では GlideRecord の読み取り結果がオブジェクトで返り、null.foo はメッセージとスタック付きで failed、スコープ表(sn_vul_*)の集計も通り、往復は 10〜18 秒でした。結果を読んだらジョブは削除します。
スクリプトジョブ方式の注意点
-
実行はスケジューラ任せ。空きワーカーがなければ待たされます(後述)。ツールは
wait_secondsを超えたら「scheduled」とジョブ ID を返し、推測はしない。ノードあたりのワーカーは 8 本で、9 本目の Burst Worker は「priority が 25 以下で 60 秒以上待たされたジョブ」にしか使われません(Understanding Scheduled Job Workers)。sysauto_scriptには priority カラムがなく既定の 100 になるので、この方式のジョブは Burst Worker の対象外です -
スコープは global 固定。
sysauto_scriptのsys_scopeは REST から設定できず(渡しても global になる)、スコープ表への cross-scope アクセスは各テーブルの Application Access に従います。今回は VR テーブルの「Can delete」がオフで、ジョブからも REST からもテスト用スキャンを削除できず、state=canceledに倒すのが精一杯でした -
実行ユーザーは作成者。作られたレコードの
sys_created_byは REST の統合ユーザーでした -
重複起動のガードは自前で。同じ対象への 2 回目の呼び出しは、製品側と同じ条件(例: Playbook なら
sys_pd_contextの未完了実行、スキャンなら m2m 経由でstate IN processing,scanning,queued)で弾きます。加えて「ジョブは積んだがまだ走っていない」窓があるので、未実行ジョブの存在もチェックしています
まとめ
- Table API の 2xx は「全部書けた」を意味しない。辞書 read-only・フィールド write ACL・存在しないカラムは黙って落ちる。書いたら読み戻す
- サーバ側でしかできない操作(状態遷移が辞書で守られている、ACL 条件が REST からは満たせない)は、run-once の
sysauto_script+syslog戻り経路が現実的な唯一の道。同期 REST エンドポイントは存在しない
この 3 ツール(run_security_playbook / scan_vulnerabilities / execute_background_script)は 1.11.3〜1.11.4 でこの方式に載せ替え、実機で往復まで確認しています。
参考
- tedorigawa001/ServiceNow-MCP ― CHANGELOG 1.11.3 / 1.11.4 に検証の詳細
-
Make a field read only(ServiceNow Docs) ―
Read only optionの 3 段階 - Scripts - Background module(ServiceNow Docs)
- Understanding Scheduled Job Workers(ServiceNow Community) ― 8 ワーカー + Burst Worker
- PUT Table API to update read only field(ServiceNow Community)
- NVD API Key Request
-
sn_vul.VulnerabilityScanUtil、sn_si_aw.AnalystWorkspaceSIRUtil.startPlaybooks― 製品側の起動パス(インスタンス上の Script Include)