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?

kSQL MCPサーバーのコード解説7:検証済みクエリをファイルに台帳化する

0
Last updated at Posted at 2026-07-03

LLM や運用者が同じ kSQL を繰り返し使う場面では、毎回 SQL 本文をそのまま渡す方式は長く、壊れやすく、レビューもしづらくなります。とくに MCP サーバーでは、「何を実行するか」だけでなく、「どの安全制御を通すか」を毎回ぶらさないことが重要です。

本稿で扱うのは、検証済みクエリを名前で登録・実行・管理する 保存 SQL カタログ です。対象コードは src/mcp/savedQueries.ts と、tools.ts にある保存系 5 ツールです。

  • ksql_save_query
  • ksql_list_queries
  • ksql_get_query
  • ksql_run_saved_query
  • ksql_delete_query

ここではこの層を単なる便利機能としてではなく、定型操作ほど安全制御を一貫させるための層として読みます。ポイントは明快で、保存クエリは独自の実行器を持ちません。第4回で見た validate / query / mutate と、第5回で見た DML ガードにそのまま合流します。つまり、名前付き再利用の入口は増えても、実行ルールの本流は増やさない設計です。

導入:定型クエリを「名前」で呼べるようにする

毎回 SQL 本文をツール引数に入れる方式は、定型操作が増えるほど弱くなります。

  • SQL が長いとプロンプトや引数が壊れやすい
  • 微妙な差分が見えにくい
  • 同じ意図のクエリが重複しやすい
  • 毎回 profile や read-only 前提を考え直すことになる

そこで、検証済みクエリを 名前付きの台帳 として保存し、一覧・取得・実行・削除できるようにします。重要なのは、保存した時点で終わりではなく、保存クエリ自体がライフサイクルを持つことです。

この図で見たいのは、実行だけが特別ではないという点です。保存 SQL カタログは「実行専用の近道」ではなく、保存・参照・運用を含む台帳です。定型操作を台帳化することで、実行前提も一緒に管理できます。

本論1:カタログの形と保存場所の解決

まず、保存 SQL カタログ自体の形はかなり素朴です。複雑な DB ではなく、JSON ファイル 1 枚の台帳として扱います。

カタログのデータ構造

SavedQueryCatalogversion: 1queries: SavedQuery[] を持つ単純な構造です。1 件の SavedQuery は SQL 本文だけでなく、どう実行される前提かまで含みます。

// src/mcp/savedQueries.ts
export interface SavedQuery {
  name: string;
  title?: string;
  description?: string;
  sql: string;
  defaultProfile: string;
  readOnly: boolean;
  allowProfileOverride?: boolean;
  createdAt: string;
  updatedAt: string;
  tags?: string[];
}

export interface SavedQueryCatalog {
  version: 1;
  queries: SavedQuery[];
}

ここで重要なのは、SavedQuery が単なる文字列ラッパーではないことです。

  • sql: 実行したい SQL 本文
  • defaultProfile: どの profile を既定実行先とするか
  • readOnly: 読み取り専用として扱うか
  • allowProfileOverride: 実行時に profile の上書きを許すか
  • createdAt / updatedAt: 台帳としての監査性
  • tags / title / description: 人間が探しやすくするメタデータ

つまり保存 SQL は、クエリ本文 + 実行上の前提をひとまとめにしたレコードです。とくに defaultProfile は必須なので、保存クエリは常に既定の実行先を持ちます。

保存場所の解決ルール

保存場所は固定ではなく、フォールバック順で解決されます。resolveSavedQueryCatalogPath の優先順位は次の通りです。

  1. ツール入力で明示されたパス
  2. 環境変数 KSQL_SAVED_QUERIES
  3. config の mcp.savedQueries.path
  4. 既定値 .ksql/queries.json

この順序にしている理由は、その場の明示指定を最優先しつつ、運用の既定値も持てるようにするためです。さらに相対パスの基準も揃えます。

  • config 由来の相対パスは 設定ファイルの位置基準
  • それ以外の相対パスは cwd 基準

たとえば config が /etc/app/config.yaml にあり、その中で queries.json を相対指定した場合、保存先は /etc/app/queries.json に解決されます。

JSON ファイル永続化では、この「どこに書かれるか」が曖昧だと運用事故になります。保存場所の解決規則を明確に持つのは、地味ですが重要です。

ツール入力のパスを最優先する設計は運用上は柔軟ですが、LLM や外部入力をそのまま通すと任意パスへの書き込みにつながりえます。実運用では、許可ディレクトリ配下への制限、path.resolve による正規化、絶対パス許可の可否などを別途決めておくべきです。

読み込み・書き込み・検証の責務分担

この層は、責務を次の 3 つに分けて読むと追いやすくなります。

  • loadSavedQueryCatalog: ファイル読み込み
  • parseSavedQueryCatalog: 構造検証
  • saveSavedQueryCatalog: ファイル書き込み

loadSavedQueryCatalogENOENT のとき空カタログを返します。つまり、初期状態でまだファイルがなくても扱えます。これは「必ず事前初期化が必要」という運用負荷を避ける設計です。

一方で parseSavedQueryCatalog は緩くしません。

  • version が正しいか
  • 必須フィールドがあるか
  • 各 query が正しい形か
  • 名前重複がないか

を厳格に検証します。壊れた JSON を読み込んでそのまま抱え込むと、あとで実行時にもっと分かりにくい壊れ方をします。不整合は読み込み時点で止めるのがこの層の方針です。

ここで重要なのは、ロード時と保存時で同名の扱いが違うことです。ロード時は parseSavedQueryCatalog既存カタログの整合性検査として名前重複を拒否します。一方、保存時は saveQueryupsertSavedQuery を通るため、同名は拒否ではなく更新として扱われます。つまり、保存時は重複をエラーにするのではなく、一意キーである name に対する明示的な upsert を行う実装です。

差分が安定する保存形式

saveSavedQueryCatalog は、保存前に name でソートし、整形 JSON で書き出します。これは実務ではかなり効きます。

  • Git の差分が安定する
  • 並び順のノイズが減る
  • レビューしやすい
  • 手編集したときの見通しが良い

単純な JSON ファイル運用でも、差分が安定するだけで保守コストはかなり下がるためです。

ただし、ここで得ているのは差分安定性であって、並行更新耐性ではありません。fs.writeFile の単純上書きは複数プロセスでの同時更新に強くないため、lost update を避けたいなら一時ファイルへの書き込みと rename、あるいはロック戦略を別途検討する必要があります。本稿の層は台帳の形式と検証に主眼があり、排他制御までは担っていないと読むのが自然です。

最小例

以下は未検証の簡略例ですが、カタログの雰囲気はこうです。

{
  "version": 1,
  "queries": [
    {
      "name": "report_daily",
      "title": "Daily report",
      "description": "日次集計用の読み取りクエリ",
      "sql": "SELECT customer_id, total FROM APP WHERE status = 'active'",
      "defaultProfile": "prod",
      "readOnly": true,
      "allowProfileOverride": false,
      "createdAt": "2026-06-01T00:00:00.000Z",
      "updatedAt": "2026-06-01T00:00:00.000Z",
      "tags": ["report", "daily"]
    }
  ]
}

ここでは SQL 本文に @prod のような環境表記を入れず、実行先は defaultProfile 側で表しています。保存 SQL カタログの主題は、クエリ本文の再利用実行先制御の分離です。

本論2:保存時の安全ルール

保存 SQL カタログの第一の安全制御は、保存時点で台帳の品質を担保することです。ここでは名前制約、readOnly と DML の整合、同名保存の意味を見ます。

名前制約は参照性を守る

保存クエリは名前で呼ばれます。したがって、名前は人間にもツールにも扱いやすくなければなりません。validateSavedQueryName は次の制約を持ちます。

  • 正規表現: ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$

つまり、

  • 先頭 1 文字は英数字
  • 後続は 0〜63 文字で、英数字・_- のみ
  • 合計は 1〜64 文字
  • 空文字は不可

です。

// src/mcp/savedQueries.ts
export function validateSavedQueryName(name: string): void {
  const ok = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name);
  if (!ok) {
    throw new Error("invalid saved query name");
  }
}

この制約は見た目以上に実務的です。曖昧な空白や記号を避けることで、キー・識別子・ファイル差分・LLM の参照対象として安定します。

readOnly と変更系(DML)の整合を保存時に確定する

次に重要なのが assertSavedQuerySafety です。ここで見ているのは、保存されるクエリの素性です。

  • readOnly: true なのに DML を含んではならない
  • readOnly: false は DML のときだけ許す

つまり、読み取りクエリを誤って更新系として登録したり、その逆を曖昧にしたりしないようにします。

// src/mcp/savedQueries.ts
export function assertSavedQuerySafety(
  input: SaveQueryInput,
  safety: { isDml: boolean; statementType: string },
): void {
  const { readOnly } = input;
  const { isDml } = safety;

  if (readOnly && isDml) {
    throw new Error("readOnly saved query cannot contain DML");
  }

  if (!readOnly && !isDml) {
    throw new Error("readOnly=false is only allowed for DML queries");
  }
}

ここで 1 点、インターフェース上は statementType も受け取っていますが、この関数の判定自体は readOnlyisDml の整合だけを見ています。したがって現状の説明として正確なのは、保存時点では validate が返す isDml を基準に readOnly との整合を確定するということです。

ただし statementType が無意味なわけではありません。validate は第4回で見た検証コアで、戻り値に isDml: booleanstatementType: string を含みます。呼び出し側の saveQuery / runSavedQuery ではその結果を受け取り、変更系の経路では第5回で扱った requireDmlApproval など周辺の制御にもつなげています。ここで未使用に見えても、検証結果は周辺の委譲契約の一部です。

saveQuery は登録前に SQL を validate し、statementTypeisDml を得たうえで assertSavedQuerySafety に渡します。流れとしてはこうです。

この図の読みどころは、readOnly を自己申告で信じていない点です。パーサ・バリデータの判定結果を使って、保存時点で整合を確定する構造になっています。加えて、保存時の同名処理は拒否ではなく upsert であるため、ここで見ているのは「新規名かどうか」ではなく「保存対象として安全かどうか」です。

名前は一意キーで、同名保存は更新になる

保存クエリは名前参照です。そのため name は曖昧一致のためのラベルではなく、一意キーとして扱われます。

ここでの実装上のポイントは、同名保存を重複エラーにしないことです。saveQueryupsertSavedQuery を通すため、既存の同名エントリがあれば 新規追加ではなく更新になります。この更新は、後勝ちで配列末尾に曖昧に積み上がる挙動ではありません。既存エントリを特定し、createdAt を保持したまま updatedAt を更新する明示的な upsert です。

一方で、ロード時には parseSavedQueryCatalog が名前重複を拒否します。つまりこの層は、

  • 保存時: name をキーに新規または更新を行う
  • ロード時: 物理ファイル上に重複名が存在しないことを検査する

という役割分担を持っています。これにより、参照名の一意性は守りつつ、運用上は同名での改訂を自然に行えます。

保存時チェックは第一段の安全制御

ここでのチェックは、実行時の安全制御を省略するためのものではありません。役割は別です。

  • 保存時: 台帳の品質保証
  • 実行時: 実行の最終防波堤

第5回の DML ガードとつなげて読むと、この層の立ち位置が見えます。保存時点で事故の芽を減らし、それでも実行時にはもう一度止める。二重化は冗長ではなく意図的です。

本論3:run_saved_query ― 実行は既存ツールに委譲する

保存 SQL カタログの設計で最も重要なのはここです。ksql_run_saved_query は、保存クエリ専用の実行経路を増やしません。既存の query / mutate と DML ガードへ委譲します。

第5回で見た DML ガード(requireDmlApproval)は、変更系(DML)の実行に対して allowDml / confirmText / dmlMaxRows を要求し、承認が揃わなければ実行を止める仕組みです。
本稿ではその内部実装には立ち入らず、保存クエリ層がその前提へどう合流するかに絞って見ます。

実行の流れ

ksql_run_saved_query の大まかな流れは次の通りです。

  1. カタログから対象 query を取得
  2. assertProfileOverrideAllowed を通す
  3. SQL を再度 validate
  4. assertSavedQuerySafety を再度適用
  5. saved.readOnly が true なら query に委譲
  6. saved.readOnly が false なら requireDmlApproval を通して mutate に委譲

図にするとこうなります。

この図の読みどころは、保存クエリだから特別な近道に入らないことです。入口は名前付きですが、実行の本流は既存実装です。

同時に、分岐条件が validated.isDml ではなく saved.readOnly である点も重要です。これは assertSavedQuerySafety 通過後には、saved.readOnlyvalidated.isDml の整合がすでに確認済みであることを前提にした分岐です。つまり、分岐前に前提を固め、分岐自体は単純に保つ設計です。

DML 保存クエリでも無条件実行にしない

ここは明記しておくべき点です。変更系(DML)の保存クエリであっても、通常の DML 実行と同じくガードを要求します。

  • allowDml
  • confirmText
  • dmlMaxRows

などです。保存済みであることは、DML ガードの免除理由にはなりません。第4回の validate / query / mutate、第5回の DML ガードと接続して読むと、保存クエリ層はそれらの上に載る 名前付き入口 であって、別系統の実行器ではないことが分かります。

実行時にも再検証する理由

「保存時に検証したのだから、実行時はそのままでよいのでは」と見えるかもしれません。しかし、この実装はそうしません。実行時にも再度 validateassertSavedQuerySafety を通します。

ここでは、構造検証SQL の意味的検証を分けて考えると整理しやすくなります。

  • loadSavedQueryCatalog / parseSavedQueryCatalog が守るもの
    • JSON として壊れていないこと
    • 必須フィールドがあること
    • 名前重複がないこと
  • 実行時の validate が守るもの
    • いま実行しようとしている SQL の statementType
    • DML かどうかの再確定
    • readOnly メタデータとの整合再確認

つまり、外部編集で JSON 構造が壊れた場合はロード時に止まります。一方で実行時再検証が守っているのは、SQL 本文のセマンティクスをその場で再確定することです。保存台帳は便利にするためのものですが、実行直前の SQL 判定まで省略しないのが安全側です。

tools.ts 側の委譲

要点だけ抜くと、runSavedQuery は分岐の責務を持ち、実行そのものは既存ツールへ渡します。

// src/mcp/tools.ts
async function runSavedQuery(input: {
  name: string;
  profile?: string;
  allowDml?: boolean;
  confirmText?: string;
  dmlMaxRows?: number;
}) {
  const saved = await getSavedQueryByName(input.name);
  assertProfileOverrideAllowed(saved, input.profile);

  const validated = validate(saved.sql);
  assertSavedQuerySafety(
    { readOnly: saved.readOnly, sql: saved.sql, defaultProfile: saved.defaultProfile },
    {
      isDml: validated.isDml,
      statementType: validated.statementType,
    },
  );

  const profile = input.profile ?? saved.defaultProfile;

  if (saved.readOnly) {
    return await runQuery({
      sql: saved.sql,
      profile,
    });
  }

  const dmlMaxRows = requireDmlApproval(
    input,
    "ksql_run_saved_query",
    "for DML saved queries",
  );

  return await runMutate({
    sql: saved.sql,
    profile,
    allowDml: true,
    confirmText: "yes",
    dmlMaxRows,
  });
}

validate の戻り値は第4回で見た検証コアの結果で、isDml: booleanstatementType: string を含みます。ここではそのうち isDmlassertSavedQuerySafety に、変更系の文脈では statementType も含めた検証結果を第5回の DML ガード連携に使います。

ここで重要なのは DML 委譲契約です。保存 DML クエリの実行では、呼び出し元の確認入力を requireDmlApproval で検証し、mutate への確認パラメータである allowDml / confirmText は委譲側で確定させます。つまり、承認の判定は run_saved_query 側で済ませ、mutate には承認済みの実行として渡す設計です。

このとき allowDml: trueconfirmText: "yes" は、利用者がそのつど入力する値ではありません。run_saved_query 側で requireDmlApproval による承認判定を済ませた結果として、mutate に「承認済みの変更系実行」であることを伝えるための固定値です。

そのため、見かけ上は runSavedQuerymutate の両方に DML ガードが関わっていても、責務は分かれています。

  • runSavedQuery: 呼び出し元の承認入力を検証する
  • mutate: 承認済みの変更系実行として実処理を受ける

この分担により、承認判定の入口実行の本流を分離したまま、既存の mutate 経路を再利用できます。

また、profileinput.profile ?? saved.defaultProfile で確定します。defaultProfile は必須なので、ここで最終的に必ず文字列の profile が決まります。保存 SQL カタログ層で profile を確定させたうえで下流へ渡す、というのが実装の事実です。

本論4:profile オーバーライドの制御

保存クエリでは、何を実行するかだけでなく、どこで実行するかも固定したい場面があります。そのための制御が assertProfileOverrideAllowed(query, requestedProfile) です。

役割

この関数の責務は単純です。

  • 要求された profile が未指定なら通す
  • 要求された profiledefaultProfile と同じなら通す
  • 異なる profile が要求されていて
  • allowProfileOverride が明示されていなければ拒否する
// src/mcp/savedQueries.ts
export function assertProfileOverrideAllowed(
  query: SavedQuery,
  requestedProfile?: string,
): void {
  if (!requestedProfile || requestedProfile === query.defaultProfile) {
    return;
  }

  if (!query.allowProfileOverride) {
    throw new Error("profile override is not allowed for this saved query");
  }
}

条件分岐自体は短いですが、事故防止にはかなり効きます。

なぜ false 既定なのか

allowProfileOverride は false 既定であるべきです。理由は、名前付き再利用で操作が軽くなるほど、実行先の切り替えまで軽くしすぎないほうがよいからです。

たとえば次のような事故を防ぎたいわけです。

  • 本番向け保存クエリを検証気分で別 profile に向ける
  • 検証用クエリを本番 profile に向ける
  • LLM が「同じクエリだから環境だけ変えてよい」と判断する

保存クエリは再利用性を高める機能ですが、環境境界まで曖昧にしてはいけません。

架空ケースで見る設計意図

たとえば、次のような意図の違いがあります。

name defaultProfile allowProfileOverride 意図
report_daily prod false 本番の定型レポート。実行先を固定したい
cleanup_temp staging true 開発・検証環境で同じ変更系操作を試したい

ここで見たいのは実データではなく、保存クエリごとに「環境境界の硬さ」を持てることです。

DML ガードとの役割分担

第5回の DML ガードが 何を実行するか への安全制御だとすると、profile オーバーライド制御は どこで実行するか への安全制御です。保存クエリではこの両方が必要になります。

  • DML ガードだけでは、誤った環境への実行を止めきれない
  • profile 固定だけでは、危険な更新操作自体は止めきれない

したがって、保存 SQL カタログでは両方を分離して持つのが自然です。

コードリーディングの要点:savedQueries.ts と tools.ts をどう読むか

ここまでの話を実コードで追うなら、中心になるのは次の要素です。

  • SavedQuery
  • resolveSavedQueryCatalogPath
  • assertSavedQuerySafety
  • assertProfileOverrideAllowed
  • tools.tsrunSavedQuery 周辺

本稿のコードは設計意図を説明するための抜粋であり、未検証の簡略例です。全文を転載するより、関数シグネチャと分岐の骨格を優先して追ったほうが、この層の責務境界は見えやすくなります。

ロード・検証・保存の骨格

// src/mcp/savedQueries.ts
export function parseSavedQueryCatalog(input: unknown): SavedQueryCatalog {
  // version の確認
  // queries が配列か確認
  // 各 query の必須フィールド確認
  // 名前重複確認
  // ...
  return {
    version: 1,
    queries: parsedQueries,
  };
}

export async function loadSavedQueryCatalog(
  path: string,
): Promise<SavedQueryCatalog> {
  try {
    const raw = await fs.readFile(path, "utf8");
    return parseSavedQueryCatalog(JSON.parse(raw));
  } catch (error: unknown) {
    if (isEnoent(error)) {
      return { version: 1, queries: [] };
    }
    throw error;
  }
}

export async function saveSavedQueryCatalog(
  path: string,
  catalog: SavedQueryCatalog,
): Promise<void> {
  const normalized: SavedQueryCatalog = {
    version: 1,
    queries: [...catalog.queries].sort((a, b) =>
      a.name.localeCompare(b.name),
    ),
  };

  await fs.mkdir(dirname(path), { recursive: true });
  await fs.writeFile(path, JSON.stringify(normalized, null, 2) + "\n", "utf8");
}

この組み合わせは、単純な JSON ファイル永続化を壊れにくくする典型パターンとして読めます。

  • パス解決を明示する
  • ロード時に厳格検証する
  • ENOENT は初期状態として扱う
  • 保存時にソートして整形する
  • 同名保存は upsert しつつ、ロード時には重複名を拒否する

つまり、DB を持たない実装でも、雑にファイルを読む書くにはしていません。ファイルベースでも台帳として運用できるように、壊れ方を先回りして潰しています。

保存 SQL カタログは見た目には軽量機能ですが、責務は「単に保存する」ことではありません。
参照性・再現性・安全制御の一貫性をファイル 1 枚で保つことが、この層の本当の仕事です。

まとめ:JSON の台帳にしつつ、安全制御は実行時まで持ち越す

本稿の要点をまとめます。

  1. 保存 SQL カタログは JSON 1 枚の台帳 として実装される
  2. 保存場所は 入力 > 環境変数 > config > 既定値 の順で解決される
  3. 安全制御は 保存時実行時 の二段構えで行う
  4. 保存時の同名は拒否ではなく upsert による更新で扱い、ロード時は 重複名を整合性エラーとして拒否する
  5. run_saved_query は専用実行器を持たず、既存の query / mutate に委譲する
  6. profile オーバーライドは一部だけに許す 明示許可制 で扱う

設計の中心にあるのは、定型化された操作ほど安全制御を一貫させるという判断です。保存したから近道に入るのではありません。むしろ、保存したものこそ既存ガードに合流させる。この一貫性が保存 SQL カタログ層の核心です。

次にコードを読むときは、次の 3 点に着目すると見通しが良くなります。

  • SavedQuery は SQL 文字列ではなく、運用前提を含んだレコードである
  • run_saved_query は新規実装より再利用を優先している
  • allowProfileOverride は環境境界の安全弁である

次回、第8回の最終回ではビルドと配布、そしてシリーズ全体の総括に進みます。

連載ナビ

参考

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?