1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ServiceNow Table APIは「書けなかった」ことを教えてくれない ― 200 OKの裏で捨てられる値と、REST でサーバスクリプトを走らせる唯一の道

1
Posted at

はじめに

自作の 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)を読むとこういう流れです。

  1. sn_vul_scan を insert
  2. 対象を m2m テーブル(sn_vul_m2m_scan_configuration_item など)でリンク
  3. 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 ジョブを各所で使っています。

戻り値をどう受け取るか

ジョブは非同期なので、結果を返す経路が要ります。試した順に:

  1. ジョブ自身の description に書き戻す → 前述のとおりカラムが存在しない。setValue は no-op で update() は成功扱い
  2. 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 でこの方式に載せ替え、実機で往復まで確認しています。

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?