ServiceNow を AI から自然言語で操作する MCP サーバーに、smart_query(自然言語→テーブル名+encoded query 自動変換)ツールを追加しました。「先月の未解決 P1 インシデント」のような指示を、テーブル解決→条件推論→クエリ実行まで一気にやるツールです。
GitHub: https://github.com/tedorigawa001/ServiceNow-MCP
smart_query 機能の強化
既存に query_records はありましたが、テーブル名(incident)とフィールド名(priority)を正確に指定する必要があります。AI クライアントはそれを自分で組み立てられますが、ユーザーの曖昧な指示を確実にクエリへ落とす「橋渡し」があると自律性が上がります。
入力: "先月の未解決 P1 インシデント"
↓ ① テーブル解決 → incident
↓ ② フィールド集合取得 → sys_dictionary(継承込み)
↓ ③ 条件推論 → priority=1 / active=true / 先月の作成日
↓ ④ query_records 実行
出力: 該当レコード + 解釈結果(encoded_query)
ポイントは ③ の「解決テーブルに存在しないフィールドの条件は捨てる」自己補正です。AI が投げた曖昧な指示でも、テーブルに priority が無ければその条件を黙って落とし、unmatched_intents として返します。
ちなみに既存の natural_language_search は中身が incident 固定のスタブだったので、それは温存しつつ別ツールとして作りました。
以下は、実装でハマった点。。
落とし穴1: split(/[^A-Za-z0-9_]+/) が日本語を全消しする
キーワード辞書(incident / change_request / …)に載っていない語は、最後の砦として sys_db_object のラベル/名前を LIKE 検索します。そのためのトークン抽出を、最初こう書きました。
// ❌ バグ版
const tokens = description
.split(/[^A-Za-z0-9_]+/) // 「英数字・_ 以外」を区切り文字に
.filter(t => t.length >= 5);
英語なら問題ありません。が、この文字クラスは ひらがな・カタカナ・漢字をすべて「区切り文字」とみなすため、日本語入力だと分割結果が空配列になります。
"勤怠管理の申請".split(/[^A-Za-z0-9_]+/)
→ ['', '', '', '', '', '', ''] // 全部区切り → 実質トークンなし
修正: 「分割」をやめて「マッチ抽出」にする
区切り文字を指定するのではなく、残したい文字種を明示的にマッチさせます。日本語は「分けたい」のではなく「拾いたい」のがポイント。
// ✅ 修正版
export function extractSearchTokens(description: string): string[] {
const matches = description.match(
/[A-Za-z0-9_]{5,}|[゠-ヿーヲ-゚]{3,}|[㐀-鿿々〇]{2,}/g
);
return (matches ?? []).slice(0, 5);
}
文字種ごとに閾値を変えているのが肝です。
| 種別 | 範囲 | 最小長 | 意図 |
|---|---|---|---|
| ASCII 語 | A-Za-z0-9_ |
5 | "WebSphere" のような部分一致ノイズを避ける |
| カタカナ |
゠-ヿ + 長音 ー + 半角 ヲ-゚
|
3 | "ワークフロー" 等の外来語テーブル名 |
| 漢字 |
㐀-鿿(CJK Ext-A〜統合漢字)+ 々〇
|
2 | "勤怠管理"・"申請" 等の熟語 |
| ひらがな | — | — | 除外 |
ひらがなを意図的に外すのが地味な工夫です。日本語のラベルキーワードは漢字熟語かカタカナ語が中心で、ひらがなは助詞・活用語尾(の・して)がほとんど。ひらがなも拾うと、漢字とひらがなが連続して 1 トークンに接着してしまいます。
"申請して承認"
漢字+ひらがなを同一クラスで拾う → ["申請して承認"] ← LIKE申請 にヒットしない
ひらがな除外(修正版) → ["申請", "承認"] ← OK
実際の抽出結果:
"勤怠管理の申請" → ["勤怠管理", "申請"] // 助詞「の」が分離
"ワークフローの設定" → ["ワークフロー", "設定"]
"申請して承認" → ["申請", "承認"] // 「して」が接着しない
教訓: [^...] の否定文字クラスで「区切る」設計は、想定外の文字種(=日本語)を全部巻き込む。多言語を相手にするなら「拾う文字を列挙してマッチ」に倒す。
ついでに横展開で、他ツールに同種([^A-Za-z...] や ASCII 前提のトークナイザ)が無いか全文 grep しました。自由文検索の主要経路(ナレッジ検索・ユーザー/グループ検索・NLQ)は、^ と NUL だけ落として日本語は保持する別実装だったため無傷。唯一の地雷が今回の smart_query でした。
落とし穴2: glide 関数には「許可リスト」がある
「先月」「過去7日」のような時間窓は、ServiceNow の glide 式に変換します。最初こう書きました。
// ❌ daysAgoStart は許可リストに無かった
`sys_created_on>=javascript:gs.daysAgoStart(7)`
このサーバーは encoded query 内の javascript:gs.* を許可リスト(SAFE_GS_PATTERN)で検証しており、許可外の関数はインジェクション対策で弾きます。daysAgoStart は許可リストに無く、実機で 1 発エラー。
ERROR: Query contains unsafe JavaScript expression:
"javascript:gs.daysAgoStart(7)…". Only standard GlideSystem functions are allowed.
許可リストにある関数(daysAgo / beginningOfToday / beginningOfLastMonth …)へ寄せて解決しました。
// ✅ 許可リスト内の関数だけを使う
`sys_created_on>=javascript:gs.daysAgo(7)`
さらに厄介なのが「週」。beginningOfThisWeek のような週境界関数は許可リストに存在しません。 そこで「今週」は daysAgo(7)、「先週」は daysAgo(14)〜daysAgo(7) の 7 日近似にして、解釈ラベルに ~last 7 days と明記しました(嘘をつかない)。
// "this week" → 厳密な週ではなく7日近似であることを明示
add('this week (~last 7 days)', dateField, `${dateField}>=javascript:gs.daysAgo(7)`);
教訓: セキュリティ用の許可リストは「実装者自身」も縛る。使いたい関数が通るか先に確認する。
落とし穴3: インジェクション安全は「テンプレート固定+整数だけ補間」
smart_query はユーザーの自由文から条件を組み立てるので、ここを雑にやると encoded query インジェクションの温床になります。設計を「出力する gs.* 式は固定テンプレートのみ。ユーザー由来で補間するのは検証済みの整数だけ」に限定しました。
// "過去 N 日" の N は parseInt → clamp(1..3650) した整数しか入らない
const n = Math.min(Math.max(parseInt(nDays[1], 10), 1), 3650);
since(`last ${n} day(s)`, `daysAgo(${n})`);
priority も priority=1 のように数値しか出さず、active=true/false・assigned_toISEMPTY も固定文字列。ユーザー文をそのままクエリへ流す箇所はゼロです。
落とし穴4: テーブル解決は「最長一致」で
キーワード辞書を素直に前方から舐めると、"change request" が task(タスク)や request に先に当たって誤爆します。
"this week change requests"
単純一致 → request(sc_request) に当たってしまう
最長一致 → "change request"(11文字) が最優先 → change_request ✅
マッチした語の長さでソートして最長を採用することで解決しました。
hits.sort((a, b) => b.len - a.len); // 長い語ほど具体的 = 優先
落とし穴5: 継承フィールドは super_class を辿る
incident の priority / active / sys_created_on は、実は親テーブル task のフィールドです。sys_dictionary をテーブル名一致で引くだけだと継承フィールドが見えず、自己補正ロジックが「priority なんて無い」と誤判定して条件を捨ててしまいます。
そこで super_class チェーンを最大 6 段辿り、nameIN<chain> で 1 クエリにまとめて取得しています。
// incident → task → … の継承チェーンを辿ってから
`nameIN${chain.join(',')}^element!=NULL` // 継承込みで 1 回引く
実機検証
ダミーではなく実 PDI に対して、テーブル解決・条件推論・継承・フォールバックを横断確認しました。
| 入力 | 解決テーブル | 手段 | encoded_query |
|---|---|---|---|
| open incidents | incident |
keyword | active=true |
| this month change requests | change_request |
keyword(最長一致) | …>=beginningOfThisMonth() |
| critical vulnerable items | sn_vul_vulnerable_item |
keyword |
priority=1(VI 3件) |
| 過去7日に作成された未解決のインシデント | incident |
keyword | active=true^…daysAgo(7) |
| 先月の P1 インシデント | incident |
keyword | priority=1^…先月 |
| P3 incidents updated yesterday | incident |
keyword | priority=3^sys_updated_on BETWEEN 昨日 |
| open ones(+table ヒント) | sn_vul_remediation_task |
hint | active=true |
| approval records | sysapproval_approver |
フォールバック(ラベル検索) | (none) |
| zzz nothing here zzz | — | NOT_FOUND(正しく失敗) | — |
まとめ
自然言語→クエリ変換ツールを作るときに効いた知見:
-
[^A-Za-z0-9_]の否定クラスで「区切る」と日本語が全消しになる。 多言語なら「拾う文字を列挙してマッチ」に倒す。ひらがなは除外すると熟語が綺麗に分離する - セキュリティ許可リストは自分も縛る。 glide 関数は使う前に通るか確認。無い関数(週境界)は安全な近似+正直なラベルで代替
- **インジェクション安全は「固定テンプレート+検証済み整数のみ補間」**で担保
- テーブル解決は最長一致で誤爆回避
- 条件の field 存在チェックは super_class 継承を辿ってから
- 英語テストだけだと日本語バグは CI を素通りする。 i18n 入力のテストを足す
なお、smart_query は Tier 0(読み取り専用)で、execute=false を付ければ実行せずに「どう解釈したか(テーブル・encoded_query・捨てた条件)」だけプレビューできます。
