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サーバーのコード解説5:多層ガードでDMLを安全に止める

0
Last updated at Posted at 2026-07-03

LLM に kintone への書き込みを許す以上、設計の主眼は「書けること」ではなく「止められること」に置かれます。

本稿では、シリーズ第5回として kSQL MCP サーバーの mutate を中心に、変更系(DML)を各層で止める実装を読みます。焦点は src/mcp/tools.tsmutate と、その周辺に置かれた DML ガードです。

第2回で見たスキーマ、第4回で見た実行コアと接続しますが、ここでは単独で読めるように要点を再確認しながら進めます。先に結論を書くと、この実装は 1 か所の if 文で守る設計ではありません。

  • スキーマで承認入力の契約を固定する
  • 実行時アサートで内部経路も含めて不変条件を守る
  • 文種別チェックで危険な DML の入口を狭める
  • WHERE 必須で全件更新・全件削除を止める
  • 件数上限を静的判定と実行前確認の二段で効かせる
  • 構造化エラーで拒否理由を呼び出し側に返す

この「多層で止める」こと自体が mutate の責務です。

導入:書き込みを許すなら、まず「止められること」を設計する

mutate の主目的は、DML を実行することそのものではありません。より重要なのは、LLM が誤って、過剰に、条件なしで壊す経路を塞ぐことです。

今回の読解対象は主に src/mcp/tools.ts です。ただし、実際には次の層がつながっています。

  • 第2回で扱った 入力スキーマ
  • 第4回で扱った validate / executeSql を含む実行コア
  • 本稿で扱う mutate の安全制御

最初に、関門の連鎖を図で見ます。

この流れの読みどころは、入口で承認を固定し、実行直前にも件数を再確認することです。前段だけでも、後段だけでも足りません。静的に分かる危険は早く止め、実行コアが観測できる危険は最後に止める、という分担になっています。図の分岐は概念図ですが、実装は switch ではなく順次適用される if ガードの連鎖です。

また、各抜粋を個別に見るより、mutate の骨格を先に押さえたほうが責務境界が見えやすくなります。

// src/mcp/tools.ts
async function mutate(input: MutateInput): Promise<Record<string, unknown>> {
  const dmlMaxRows = requireDmlApproval(input, "ksql_mutate");

  const validation = await validate(input);
  if (!validation.isDml) {
    throw new Error(`ArgumentError: ${validation.statementType} is not allowed by ksql_mutate. Use ksql_query.`);
  }
  if (validation.statementType === "INSERT_SELECT" || validation.statementType === "UPSERT_SELECT") {
    throw new Error(`ArgumentError: ${validation.statementType} is not supported by ksql_mutate yet.`);
  }
  if ((validation.statementType === "UPDATE" || validation.statementType === "DELETE") && !validation.hasWhere) {
    throw new Error(`ArgumentError: ${validation.statementType} without WHERE is blocked by ksql_mutate.`);
  }
  if (validation.insertValuesCount !== null && validation.insertValuesCount > dmlMaxRows) {
    throw new Error(`ArgumentError: INSERT rows (${validation.insertValuesCount}) exceed dmlMaxRows (${dmlMaxRows}).`);
  }

  const runtime = await createRuntime(serverOptions, {
    sql: input.sql,
    profile: input.profile,
    maxRecords: dmlMaxRows + 1,
    onLimit: DEFAULT_ON_LIMIT,
    timeout: input.timeout,
  });
  const result = await executeSql(runtime.sql, runtime.client, {
    maxRecords: runtime.maxRecords,
    onLimitReached: runtime.onLimit,
    cacheContext: runtime.cacheContext,
    confirm: async (count, operation) => {
      if (count > dmlMaxRows) {
        throw new Error(`ArgumentError: ${operation} affected rows (${count}) exceed dmlMaxRows (${dmlMaxRows}).`);
      }
      return true;
    },
  });
  if (result.type === "SELECT") {
    throw new Error(`ArgumentError: ksql_mutate returned unexpected result type ${result.type}.`);
  }
  return toMutationPayload(result);
}

この骨格を見ると、mutate は「validate して executeSql する薄いラッパー」ではありません。承認、文種別、条件有無、件数上限を束ねる安全境界として実装されています。さらに末尾で SELECT を異常系として弾き、最終的な payload 整形まで mutate が担います。

本論1:明示承認の二重化は、冗長ではなく多層防御

DML の承認は 1 回だけ確認して終わり、ではありません。kSQL MCP では、スキーマ層実行時層で二重に固定します。

まずスキーマ層では、DML 実行に必要な契約を入力時点で固定します。具体的には、allowDmltrue しか受け付けず、confirmText"yes" を要求し、dmlMaxRows は正整数であることを求めます。

// src/mcp/schemas.ts
export const mutateInputSchema = z.object({
  sql: z.string().min(1),
  profile,
  allowDml: z.literal(true),
  confirmText: z.literal("yes"),
  dmlMaxRows: z.number().int().positive(),
  timeout,
});

これは「DML をやってよいか」を曖昧な真偽値で受けるのではなく、明示的な承認を伴う入力しか mutate に入れないための契約です。

ただし、それだけでは十分ではありません。実行時にも requireDmlApproval が置かれています。

// src/mcp/tools.ts
function requireDmlApproval(
  input: { allowDml?: unknown; confirmText?: unknown; dmlMaxRows?: unknown },
  toolName: string,
  suffix = ""
): number {
  const context = suffix ? ` ${suffix}` : "";
  if (input.allowDml !== true) {
    throw new Error(`ArgumentError: allowDml: true is required by ${toolName}${context}.`);
  }
  if (input.confirmText !== "yes") {
    throw new Error(`ArgumentError: confirmText: "yes" is required by ${toolName}${context}.`);
  }
  if (!Number.isInteger(input.dmlMaxRows) || Number(input.dmlMaxRows) <= 0) {
    throw new Error(`ArgumentError: dmlMaxRows must be a positive integer for ${toolName}${context}.`);
  }
  return Number(input.dmlMaxRows);
}

ここで重要なのは、各条件が個別メッセージで弾かれている点です。

  • allowDml !== true
  • confirmText !== "yes"
  • dmlMaxRows が正整数でない

これを「スキーマで見たのに、また同じことを見ている」と解釈すると設計意図を見誤ります。役割は異なります。

役割 守っているもの
スキーマ 入口の契約固定 ツール入力の形式
実行時アサート 不変条件の再確認 内部呼び出しや将来の入口追加を含む実行経路

スキーマは「正しい入口」を作ります。一方で実行時アサートは、「入口が増えても壊れない内部境界」を作ります。たとえば将来、別ツールや保存 SQL 実行経路から mutate 相当の処理が呼ばれても、この関数を通る限り DML 承認の不変条件は崩れません。suffix / context の仕組みでメッセージ末尾に文脈を足せるため、保存 SQL の DML 実行のような内部経路でも同じ検査を流用できます。

つまり二重化は冗長ではなく、防御対象の異なる層で同じ前提を守る実装です。

なお、requireDmlApproval の引数は unknown で受けられています。これはスキーマを通過しない内部経路も想定した防御です。そのうえで !Number.isInteger(...)Number(...) <= 0 の二段で、dmlMaxRows が正整数であることを実行時に保証しています。

本論2:文種別の制限と WHERE 必須で、危険な DML の入口を狭める

承認があっても、どんな SQL でも実行してよいわけではありません。次の関門は、文種別そのものを絞ることです。

mutate はまず validate(input) を通し、その結果をフラットな validation オブジェクトとして受けます。ここで使うのは validation.statementTypevalidation.isDmlvalidation.hasWherevalidation.insertValuesCount といったフィールドです。

// src/mcp/tools.ts
const validation = await validate(input);

if (!validation.isDml) {
  throw new Error(`ArgumentError: ${validation.statementType} is not allowed by ksql_mutate. Use ksql_query.`);
}

ここでの意図は明快です。mutate は「なんでも SQL を流せる汎用口」ではなく、DML に責務を限定したツールです。責務境界を曖昧にしないこと自体が安全制御になります。

さらに DML の中でも、一律に許しません。とくに INSERT_SELECTUPSERT_SELECT は未対応として明示的に拒否します。

// src/mcp/tools.ts
if (validation.statementType === "INSERT_SELECT" || validation.statementType === "UPSERT_SELECT") {
  throw new Error(`ArgumentError: ${validation.statementType} is not supported by ksql_mutate yet.`);
}

ここは switch ではなく、文字列比較の if を順次重ねる実装です。未対応の文種別だけを明示的に塞ぎ、通してよいものは次のガードに進めます。

これは機能不足ではなく、安全側に倒す判断です。SELECT 起点の DML は、入力集合の広がりが読みづらく、影響件数の見積もりも難しくなります。LLM に生成させる文としては、影響範囲の予測可能性が落ちます。そのため、対応可否を曖昧にせず、未対応として入口で止めるわけです。

次に、もっとも危険な操作である全件更新・全件削除を止めます。UPDATEDELETEWHERE なしを拒否します。

// src/mcp/tools.ts
if ((validation.statementType === "UPDATE" || validation.statementType === "DELETE") && !validation.hasWhere) {
  throw new Error(`ArgumentError: ${validation.statementType} without WHERE is blocked by ksql_mutate.`);
}

このガードの意味は、SQL として正しいかどうかではありません。破壊的な全件操作を明確に不許可にすることです。

LLM は曖昧な指示から SQL を生成します。そのとき、「条件が弱い」ことまでは完全に防げなくても、「条件なし全件」だけは確実に止められます。ここで引いている境界は、完璧な意図理解ではなく、最悪ケースを確実に潰す境界です。

WHERE 必須は、UPDATE / DELETE が安全になることを意味しません。ここで守っているのは「条件なし全件」という最も危険な形を通さないことです。安全制御は 1 つで完結せず、件数上限や confirm フックと組み合わせて初めて意味を持ちます。

本論3:件数上限は、静的判定と実行前確認で分けて扱う

DML で難しいのは、影響件数が文種別によって異なることです。静的に数えられるものと、実行コアが観測して初めて分かるものが混在します。kSQL MCP はここを 1 つの仕組みで雑に処理せず、二段に分けています。

背景として、kintone の REST API ではレコードの一括登録・更新・削除に 一度に100件まで という上限があります。また、各リクエストは失敗時にそのリクエスト内の操作がすべてキャンセルされる、1 リクエスト単位の all-or-nothing で扱われます。dmlMaxRows のような件数ガードは kSQL 独自の安全装置であると同時に、kintone 側の 100件上限 という現実的な制約とも整合する設計だと読めます。

INSERT ... VALUES は静的に数えられるので、実行前に止める

INSERT ... VALUES (...) は、VALUES の件数を静的に数えられます。そのため validation.insertValuesCount を使い、dmlMaxRows 超過なら実行前に拒否します。

// src/mcp/tools.ts
if (validation.insertValuesCount !== null && validation.insertValuesCount > dmlMaxRows) {
  throw new Error(`ArgumentError: INSERT rows (${validation.insertValuesCount}) exceed dmlMaxRows (${dmlMaxRows}).`);
}

ここで重要なのは、分岐条件が validation.statementType === "INSERT" ではないことです。実装は validation.insertValuesCount !== null かどうかで見ています。つまり、件数を数えられる文だけがこの静的チェックの対象です。null のときは、INSERT ... VALUES ではない、または静的に件数を数える対象ではないため、この段ではスキップされます。

ここでの境界条件は、validation.insertValuesCount > dmlMaxRows のときに拒否、すなわち 上限ちょうどまでは許可です。これは後述する UPDATE / DELETE 側の判定とも揃っています。dmlMaxRows は「超えてはいけない上限」であって、「未満でなければならない閾値」ではありません。

これは最も素直な防御です。静的に分かる危険を、わざわざ実行フェーズまで持ち込まない。拒否コストが低い場所で早く止めています。

UPDATE / DELETE は、実行コアが観測する候補件数で止める

一方で UPDATE / DELETE は、対象件数が実データに依存します。WHERE があっても、それが何件に当たるかは SQL 文字列だけでは確定しません。

そこで createRuntimemaxRecords: dmlMaxRows + 1 を渡します。

// src/mcp/tools.ts
const runtime = await createRuntime(serverOptions, {
  sql: input.sql,
  profile: input.profile,
  maxRecords: dmlMaxRows + 1,
  onLimit: DEFAULT_ON_LIMIT,
  timeout: input.timeout,
});
  • count は観測値です
  • maxRecords = dmlMaxRows + 1 は超過検知用です
  • count > dmlMaxRows で拒否します

違うのは、UPDATE / DELETE ではその count を静的に出せないため、実行コアが観測できる形にしている点です。

その観測結果を受けるのが、executeSql に渡す confirm フックです。

// src/mcp/tools.ts
const result = await executeSql(runtime.sql, runtime.client, {
  maxRecords: runtime.maxRecords,
  onLimitReached: runtime.onLimit,
  cacheContext: runtime.cacheContext,
  confirm: async (count, operation) => {
    if (count > dmlMaxRows) {
      throw new Error(`ArgumentError: ${operation} affected rows (${count}) exceed dmlMaxRows (${dmlMaxRows}).`);
    }
    return true;
  },
});

ここでの confirm は、単なる UI 的な「本当に実行しますか」ではありません。実行コアが観測した候補件数に対する最終ガードです。

重要なのは、confirm の引数がオブジェクトではなく 位置引数 (count, operation) である点です。エラーメッセージにも operation が入り、どの操作が上限を超えたかを payload 側で区別しやすくしています。

本稿の文脈では、少なくとも次のように読むのが安全です。

  • countUPDATE / DELETE の候補件数として実行コアが観測した値
  • maxRecords = dmlMaxRows + 1 は、その観測を上限超過検知に使うための制限
  • したがって count > dmlMaxRows を検知できれば、mutate はそこで拒否する

この設計を整理すると次の通りです。

対象 件数の分かり方 ガード位置 許可境界
INSERT ... VALUES 静的に数えられる 実行前 rows <= dmlMaxRows
UPDATE / DELETE 実行コアが候補件数を観測する confirm フック count <= dmlMaxRows

つまり、

  • 静的に分かる件数は前で止める
  • 実行コアが観測する件数は直前で止める

という分担です。

createRuntimemaxRecords と実行経路の責務分担

ここでの実装は、実行用のオブジェクトをスプレッドで加工する形ではありません。createRuntime(serverOptions, { ..., maxRecords: dmlMaxRows + 1, ... }) で、最初から上限観測向けの runtime を組み立てる形です。

その runtime を executeSql(runtime.sql, runtime.client, options) に渡し、options 側で maxRecordsonLimitReachedcacheContextconfirm を明示します。つまり mutate は「事前に何を止めるか」を決めるだけでなく、実行コアにどの上限条件で観測させるかまで責務として持っています。

ただし、これは万能な保証ではない

ここで 1 つ限界も明記しておきます。confirm で候補件数を確認し、その後に実際の UPDATE / DELETE が走る構造なら、確認時点と実行時点の間にデータが変わる余地があります。いわゆる TOCTOU です。

本稿の抜粋だけでは、確認と実行が同一トランザクション、または同一のアトミックな操作として束ねられているかまでは断定できません。したがって、このガードは 実用上かなり有効な上限検知ではありますが、DB レベルで完全に原子的な保証を与える、とまでは言い切れません。

confirm フックは強い安全装置ですが、確認と実行が完全にアトミックでないなら、確認後に対象件数が変動する余地は残ります。本実装は「何も見ずに実行する」よりはるかに安全ですが、競合更新まで完全排除する保証とは分けて読むべきです。

本論4:拒否理由は構造化して返し、黙って失敗させない

安全制御は、止めるだけでは不十分です。何を、なぜ止めたかが観測できなければ、LLM 側も呼び出し側プログラムも適切に扱えません。

そこで toErrorPayload(err) が、拒否理由を構造化して返します。

// src/mcp/tools.ts
function toErrorPayload(err: unknown) {
  const message = err instanceof Error ? err.message : String(err);
  const codeMatch = message.match(/^([A-Za-z]+Error):/);
  const errorName = err instanceof Error && err.name !== "Error" ? err.name : null;
  return {
    ok: false,
    error: {
      code: errorName ?? codeMatch?.[1] ?? "Error",
      message,
    },
  };
}

ポイントは code の抽出順です。

  • まず err.name を見る。ただし "Error" のときは採用しない
  • 次にメッセージ先頭の XxxError: パターンから取る
  • どちらも無ければ "Error" にフォールバックする

そのうえで、返り値を次の形に揃えます。

{
  ok: false,
  error: {
    code,
    message
  }
}

この payload は、第4回で見た runSafely を経由して isError: true の結果として返されます。つまり、例外を握りつぶしたり、生文字列だけを返したりするのではなく、機械可読なエラーとして外に出しています。

この設計の意図は 2 つあります。

  1. LLM と呼び出し側プログラムの双方が解釈できる形にすること
  2. ガードが何を止めたかを観測可能にすること

一方で、/^([A-Za-z]+Error):/ という抽出は、メッセージ文字列の命名規約に依存するやや脆い方法でもあります。ArgumentError のような規約化された名前なら扱いやすい反面、メッセージ形式が崩れると分類精度は落ちます。

より強い設計を目指すなら、本来は Error サブクラスや明示的な code プロパティで運ぶほうが堅牢です。この実装はその理想形ではなく、既存の例外メッセージから機械可読性を引き出す実務的な折衷として読むのが妥当です。

多層ガードは、運用で必ず「拒否される」ことを前提に設計すべきです。拒否が例外的事象ではなく通常系に近いなら、エラーもまた設計対象になります。

ここでの線引きも明確にしておきます。本稿で見たガード群が主に防ぐのは、LLM が誤って・過剰に・条件なしで変更系(DML)を実行してしまう事故です。一方で confirmText: "yes" のような承認入力は呼び出し側が値として埋めること自体は構造上可能なので、その入力まで無条件に信頼してよいわけではありません。プロンプトインジェクション等で承認用の入力が満たされる経路は別の脅威クラスであり、ここはサーバー側ガードだけで完結させず、ホストや人間による承認の層で扱うべきです。

まとめ:AI に書き込みを許す設計では、多層ガードを最後まで貫く

最後に、mutate の DML ガードを順に並べ直します。

  1. スキーマのリテラル型で承認契約を固定する
  2. **実行時 requireDmlApproval**で不変条件を再確認する
  3. 文種別チェックで非 DML や未対応 DML を拒否する
  4. WHERE 必須で全件更新・全件削除を止める
  5. 件数上限を静的チェックと confirm フックの二段で効かせる
  6. 構造化エラーで拒否理由を返す

入口から実行直前までの要点を縮約すると、mutate は次のような関門の連鎖です。

// src/mcp/tools.ts
async function mutate(input: MutateInput): Promise<Record<string, unknown>> {
  const dmlMaxRows = requireDmlApproval(input, "ksql_mutate");

  const validation = await validate(input);
  if (!validation.isDml) {
    throw new Error(`ArgumentError: ${validation.statementType} is not allowed by ksql_mutate. Use ksql_query.`);
  }
  if (validation.statementType === "INSERT_SELECT" || validation.statementType === "UPSERT_SELECT") {
    throw new Error(`ArgumentError: ${validation.statementType} is not supported by ksql_mutate yet.`);
  }
  if ((validation.statementType === "UPDATE" || validation.statementType === "DELETE") && !validation.hasWhere) {
    throw new Error(`ArgumentError: ${validation.statementType} without WHERE is blocked by ksql_mutate.`);
  }
  if (validation.insertValuesCount !== null && validation.insertValuesCount > dmlMaxRows) {
    throw new Error(`ArgumentError: INSERT rows (${validation.insertValuesCount}) exceed dmlMaxRows (${dmlMaxRows}).`);
  }

  // ...
}

この設計の核は、書けることよりも止められることを各層で担保する点にあります。

二重化、多層化は、AI に権限を与える実装における過剰防御ではありません。前提条件を崩さないための境界設計です。スキーマだけに頼らず、実行時だけにも頼らず、文種別だけにも頼らない。複数の関門を意図的に重ねることで、LLM に書き込みを許す前提でも危険な操作を止められるようにしています。

同時に、本稿で見た件数上限は万能な魔法でもありません。INSERT は静的に数えられるから前で止める。UPDATE / DELETE は候補件数を観測して直前で止める。ただし、確認と実行の原子性までは別問題です。このように、何を強く保証し、何は限界として残るかを切り分けている点も、この実装の重要な読みどころです。

加えて、mutate は実行後に result.type === "SELECT" を異常系として弾き、最後に toMutationPayload(result) で呼び出し側向けの payload に整形します。安全に止めるだけでなく、許可した変更系の結果を一貫した形式で返すところまでがこのツールの責務です。

次回、第6回では、これらのガードが載るランタイムと profile に進みます。

連載ナビ

参考

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?