0
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 MCPの自然言語クエリを強化してみた

0
Posted at

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 固定のスタブだったので、それは温存しつつ別ツールとして作りました。

<実行例>
Screenshot 2026-06-30 at 23.57.10.png

以下は、実装でハマった点。。

落とし穴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/falseassigned_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 を辿る

incidentpriority / 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(正しく失敗)

まとめ

自然言語→クエリ変換ツールを作るときに効いた知見:

  1. [^A-Za-z0-9_] の否定クラスで「区切る」と日本語が全消しになる。 多言語なら「拾う文字を列挙してマッチ」に倒す。ひらがなは除外すると熟語が綺麗に分離する
  2. セキュリティ許可リストは自分も縛る。 glide 関数は使う前に通るか確認。無い関数(週境界)は安全な近似+正直なラベルで代替
  3. **インジェクション安全は「固定テンプレート+検証済み整数のみ補間」**で担保
  4. テーブル解決は最長一致で誤爆回避
  5. 条件の field 存在チェックは super_class 継承を辿ってから
  6. 英語テストだけだと日本語バグは CI を素通りする。 i18n 入力のテストを足す

なお、smart_query は Tier 0(読み取り専用)で、execute=false を付ければ実行せずに「どう解釈したか(テーブル・encoded_query・捨てた条件)」だけプレビューできます。

0
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
0
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?