はじめに
自作のServiceNow MCPサーバー(tedorigawa001/ServiceNow-MCP、npm公開中)に、Data Management(アーカイブ・破棄・クリーンアップ)のツール群を13本追加しました。
このモジュールが扱うのは「レコードを永久に消す」操作です。
ミスが即データ消失に直結するため、実装段階から安全設計にかなり気を使いました。
Data Managementとは
ServiceNowの標準機能で、テーブル単位にルールをまとめる以下の論理構造を持ちます。
なお、sys_archive と sys_auto_flush が sys_dm_policy を直接参照する物理的な親子関係ではありません。
MCPでは対象テーブル名を使って、有効なPolicyの存在を照合しています。
- Policy: どのテーブルを対象にするかを宣言する土台
- Archive Rule: 条件に合うレコードをライブテーブルからアーカイブテーブルへ移動(可逆)
- Destroy Rule: アーカイブ済みデータを一定期間後に完全削除(不可逆)
- Cleanup Rule(Auto Flush): アーカイブを経由せず、ライブテーブルのレコードを直接削除(不可逆)
安全設計
設計方針としては、作成系ツールは常に非アクティブなルールしか作らない—
つまり create_archive_rule や create_destroy_rule を呼んでも、その時点では何も起きません。
実際にデータが動く/消えるのは、別途 set_*_active を呼んで明示的に有効化した時だけです。
const created = await client.createRecord('sys_archive_destroy', {
// ...
active: false, // 常に非アクティブで作成
});
有効化には2段階のゲートを掛けています。
-
確認フレーズの一致:
confirmation: "I_UNDERSTAND"を渡さないと拒否 - 対象テーブルにアクティブなPolicyが存在すること: 土台となるPolicyがアクティブでない状態でルールだけ動かせてしまう事故を防ぐ
async function assertActivePolicyForTable(client: ServiceNowClient, table: string): Promise<void> {
const policies = await client.queryRecords({
table: 'sys_dm_policy', query: `tablename=${table}^active=true`, limit: 1,
});
if (!policies.records.length) {
throw new ServiceNowError(`An active Data Management Policy is required before activating a rule for ${table}.`, 'CONFLICT');
}
}
実機検証で見つけたバグの話: チェック漏れ
Archive・Destroy・Cleanupの3種類の set_*_active ツールがある中で、コードを一通り読んだ時点では気づきませんでした。
全部似た形をしているので、目視レビューでは「たぶん同じパターンだろう」と読み飛ばしてしまいがちです。
そこで実際にPDI上で、**「架空のテーブル向けに、Policyを非アクティブなまま、Destroy Ruleだけを確認フレーズ付きで有効化してみる」**というテストをしました
(アーカイブ済みデータが存在しない架空テーブルを使ったので、万一通ってしまっても実害はゼロという設計にしてあります)。
[OK] set_archive_rule_active: ポリシー非アクティブ → 正しく拒否
[!!] set_destroy_rule_active: ポリシー非アクティブでも有効化できてしまった
set_destroy_rule_active にだけ assertActivePolicyForTable の呼び出しが無かったのです。3つとも同じ見た目の関数なのに、1つだけガードが抜けている——典型的な「コピペ実装の抜け漏れ」パターンでした。
修正: 参照を辿ってPolicyまでたどり着く
Destroy RuleはArchive Ruleに archive フィールドで紐づいており、テーブル名を直接持っていません。そこで参照を1段解決するヘルパーを追加しました。
function referenceSysId(value: unknown, field: string): string {
if (typeof value === 'string') return requireSysId(value, field);
if (value && typeof value === 'object' && 'value' in value) {
return requireSysId((value as { value: unknown }).value, field);
}
throw new ServiceNowError(`${field} must contain a 32-character sys_id`, 'INVALID_REQUEST');
}
case 'set_destroy_rule_active': {
// ...
if (args.active === true) {
const destroyRule = await client.getRecord('sys_archive_destroy', sysId);
const archiveRule = await client.getRecord('sys_archive', referenceSysId(destroyRule.archive, 'destroy rule archive'));
await assertActivePolicyForTable(client, requireTableName(archiveRule.table, 'archive rule table'));
}
// ...
}
referenceSysId はREST APIの参照フィールドが「文字列」と「{value, link} オブジェクト」の2形態で返ってくる曖昧さを吸収しつつ、最終的に32桁sys_idの正規表現で厳格に検証します。ここでも自由入力をそのままクエリに埋め込まない、という一貫した方針を守っています。
修正後、同じテストを再実行すると期待通り拒否されました。
さらに、この拒否経路を再現する単体テストを追加しました。以後は、親Archive Rule経由で有効Policyを解決する処理が抜けると回帰テストで検出できます。
[OK] set_destroy_rule_active もポリシー非アクティブで拒否:
An active Data Management Policy is required before activating a rule for x_mcp_dm_fix_669222.
もう一つの実機発見: Cleanup Ruleは架空テーブルを許さない
余談ですが、検証中に別の非対称性も見つかりました。sys_dm_policy.tablename と sys_archive.table は存在しないテーブル名(x_mcp_dm_verify_xxxxxx)でもレコード作成を受け付けてくれましたが、sys_auto_flush(Cleanup Rule)はPDIで作成を拒否されました。ServiceNow側のテーブルごとの実装差で、コード側の問題ではありません。実機を触らないと分からない類の挙動です。
まとめ
- 「作成は無効状態で作成、有効化を明に求める」という安全設計自体はレビューで問題なく確認できた
- しかし3つの関数のうち1つだけガードが抜けている非対称バグは、コードを読むだけでは見つけられず、実際に危険な操作を(実害ゼロの条件で)実行してみて初めて発覚した
- 破棄・クリーンアップ系のような不可逆操作を扱うツールは、ユニットテストのモックだけでなく、実機での「意地悪テスト」を組み込む価値が高い
ツールはnpmで公開済みです。