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サーバーのコード解説1:設計思想と全体像

0
Last updated at Posted at 2026-06-30

MCP サーバーを作ると、LLM(AI)から外部システムを操作できるようになります。便利な反面、kintone のような業務データを扱うなら、「間違って更新・削除されないか」「どこで止めるのか」を先に設計しておく必要があります。本記事は、その「止めどころ」を公開境界の設計として読むための導入です。

MCP サーバーを書くことは、LLM に「どの道具をどう渡すか」を設計することと同義です。とりわけ対象が kintone の業務データであれば、安全設計は機能要件と同列で扱う必要があります。

本記事は @rex0220/kintone-sql-tools の MCP サーバー実装 src/mcp を題材に、その核となる安全設計の3本柱を整理します。

  • read-only と DML のツール分離
  • validate-first
  • 明示的承認つきの多層 DML ガード

あわせて、mcp → node → core からなる層構造の地図も示します。第1回として、個別アルゴリズムよりも「なぜこの形なのか」という設計思想と全体像にフォーカスします。

導入

@rex0220/kintone-sql-tools は、kintone を SQL 風構文で扱うツール群です。本稿で見るのは、そのうち MCP サーバー実装である src/mcp です。

MCP サーバーは、Claude Desktop などの MCP ホスト(内部に MCP クライアントを持つ)に対して、どのツールをどう公開するかを定義する側です。ここでの主題は、SQL をどう実行するかだけではありません。LLM に何を許し、何を止めるかを、公開境界の設計として埋め込むことです。

本記事では、以降の深掘りの前提となる全体像を押さえます。各ファイルの詳細実装は後続回で追いますが、まずは「どこで止める設計なのか」を俯瞰します。なお、第1回の範囲は MCP の公開境界と安全設計の3本柱に絞ります。パーサ・実行エンジンの内部(lexer / parser / converter / engine)の詳細には踏み込まず、その解析パイプラインの中身は第3回で扱います。

連載一覧(全8回)

この記事はシリーズの第1回(導入)です。各回は独立して読めますが、全体像から入ると設計意図がつかみやすくなります。

  1. 設計思想と全体像(本記事)
  2. Zodでツール入力の境界を定義する
  3. SQLをkintone APIに変換する解析パイプライン
  4. validateを起点に実行経路を振り分ける
  5. 多層ガードでDMLを安全に止める
  6. 複数kintone環境をAPP@profileで切り替える
  7. 検証済みクエリをファイルに台帳化する
  8. esbuildで1ファイルに固めて配布する(最終回)

本論1:なぜ read-only と DML を別ツールに分けるのか

結論から言うと、ksql_queryksql_mutate を分けるのは、ツール境界そのものを安全装置にするためです。

  • ksql_query: 参照系
  • ksql_mutate: 変更系(DML)
  • query 側は DML を拒否
  • mutate 側は read-only 文を拒否

この構造は、src/mcp/tools.ts で SQL の文種別を見て分岐することで実現されます。SQL 文字列だけを見て権限分岐するのではなく、「どのツールを呼んだか」自体を制約に使っています。

なぜ 1 ツールにまとめないのか

代替案としては、execute のような単一ツールに権限フラグを持たせる設計もありえます。ですが、この方式には次の弱点があります。

  • LLM がフラグを誤って立てる余地がある
  • 参照と更新の意図がツール名から読み取りにくい
  • 呼び出し時のレビュー観点が曖昧になる

そのため本実装では、read-only を既定にし、書き込みに到達するには別ツールを選ぶ必要がある形を採っています。業務データ前提では、この制約は理にかなっています。

ツール全体の分類

MCP サーバーが公開する 11 ツールは、参照系・変更系(DML)・保存系として捉えると見通しがよくなります。

この図で見たいのは、参照系が既定であり、変更系(DML)が明示的に分離されていることです。保存系は、会話をまたぐ再利用性を担う層として独立しています。

また、保存クエリ用の update 専用ツールは存在しません。保存は ksql_save_query が upsert として担います。

11 ツールの一覧

本文中の「11 個のツール」は、src/mcp/index.ts で登録される次の実ツール名を指します。

分類 ツール名 役割
参照系 ksql_validate kintone API を呼ばずに kSQL を解析・検証する
参照系 ksql_explain kintone API を呼ばずに実行計画を返す
参照系 ksql_query SELECT / WITH / UNION / EXPLAIN / SHOW APPS / DESCRIBE など read-only 文を実行する
変更系(DML) ksql_mutate allowDml / confirmText / dmlMaxRows の安全制御つきで DML を実行する
参照系 ksql_describe_app DESCRIBE APP で指定アプリのフィールド定義を返す
参照系 ksql_show_apps SHOW APPS でプロファイルのアプリ一覧を返す
保存系 ksql_save_query 検証済み kSQL をローカルの保存クエリカタログに保存する
保存系 ksql_list_queries 保存クエリのメタデータ一覧を返す
保存系 ksql_get_query 名前で保存クエリを取得する
保存系 ksql_run_saved_query 保存クエリを実行する
保存系 ksql_delete_query 保存クエリを削除する

本稿の主題は read-only と DML の境界であり、保存系ツール(保存 SQL カタログ)は後続回(第7回)で扱います。ここでは公開機能群の全体像として位置づけるにとどめます。

以降のコード例では、構造理解に必要な ksql_queryksql_mutate を中心に見ます。

query / mutate の境界で止める

以降のコードは、型や分岐を簡略化した設計意図説明のための要点抜粋であり、正確な実装は該当の src/... を参照してください。QueryInput / MutateInputvalidate の戻り値型は別所定義であり、ここでは最小限の形だけ見えるようにしています。

// src/mcp/tools.ts
type ValidationResult = {
  ok: true;
  statementType: string;
  isReadOnly: boolean;
  isDml: boolean;
  hasWhere: boolean;
  insertValuesCount: number | null;
  appIds: number[];
  canRunWithQueryTool: boolean;
  requiresMutationTool: boolean;
  normalizedSql: string;
  hasProfileSyntax: boolean;
  cacheContext: string;
  appBindings: Array<unknown>;
};

export async function queryTool(input: QueryInput) {
  const validation: ValidationResult = await validate(input);

  if (!validation.isReadOnly) {
    throw new Error("ksql_query accepts read-only statements only");
  }

  return executeReadOnly(validation, input);
}

export async function mutateTool(input: MutateInput) {
  const dmlMaxRows = requireDmlApproval(input, "ksql_mutate");
  const validation: ValidationResult = await validate(input);

  if (!validation.isDml) {
    throw new Error("ksql_mutate accepts DML statements only");
  }

  if (
    validation.statementType === "INSERT_SELECT" ||
    validation.statementType === "UPSERT_SELECT"
  ) {
    throw new Error("INSERT_SELECT and UPSERT_SELECT are not supported yet");
  }

  if (
    (validation.statementType === "UPDATE" ||
      validation.statementType === "DELETE") &&
    !validation.hasWhere
  ) {
    throw new Error("UPDATE / DELETE without WHERE is blocked");
  }

  if (
    validation.insertValuesCount !== null &&
    validation.insertValuesCount > dmlMaxRows
  ) {
    throw new Error("INSERT exceeds dmlMaxRows");
  }

  return executeMutation(validation, input, dmlMaxRows);
}

この抜粋で見たいのは、mutate の責務が DML の仕分けだけではないことです。

  • requireDmlApproval(input, "ksql_mutate")allowDml / confirmText / dmlMaxRows を先に確認する
  • validate(...) で文種別と構造を確定する
  • isDmlmutate に入れる文だけを通す
  • INSERT_SELECT / UPSERT_SELECT を明示的に拒否する
  • UPDATE / DELETEWHERE 必須条件を確認する
  • INSERT の静的件数を insertValuesCountdmlMaxRows で比較する
  • そこまで通って初めて実行に進む

判定基準も揃っています。

  • queryisReadOnly のみ通す
  • mutateisDml のみ通す
  • そのどちらでもない文種別は両方で拒否する

つまり、isReadOnlyisDml の二値だけで全 SQL を覆う前提ではありません。DDL や未対応文種別のように、どちらにも属さない第三のカテゴリがありえます。その場合は、どちらのツールでも受け付けないのが正しい挙動です。

本論2:検証先行(validate-first)― 実行の前に必ず構造を確かめる

2本目の柱は validate-first です。validate は補助ツールではなく、実行の前提条件として置かれています。

要するに、querymutate も SQL を直接実行に渡さず、必ず一度 validate を通して、文種別・read-only / DML 性・WHERE の有無・件数上限の判断材料を確定してから実行経路を選びます。

ksql_validate は kintone API を呼ばずに kSQL を解析し、文の種別と実行可否の判断に必要な構造情報を返します。本稿で主に見るのは次のフィールドです。

フィールド 意味 本稿での使いどころ
statementType 文種別 query / mutate の分岐、未対応文種別の拒否
isDml DML かどうか ksql_mutate に通せるかの判定
isReadOnly read-only かどうか ksql_query に通せるかの判定
hasWhere WHERE を持つか UPDATE / DELETE の追加ガード
insertValuesCount INSERT ... VALUES の静的件数 dmlMaxRows との比較
appIds 対象アプリ ID 一覧(number[] 後段の実行制御と接続する識別情報

そのほかにも、内部用や後続回で扱うフィールドを持ちます。

querymutate も必ず validate を通る

利用者が明示的に validate を呼ぶかどうかにかかわらず、querymutate の内部でも必ず検証が先に走ります。validate-first は運用ルールではなく、実装上の強制です。

この図の読みどころは、検証が実行経路の前段に固定されていることです。querymutate は直接 kintone API に向かいません。まず文種別と構造を確定し、その結果を前提に分岐します。

no-op client の意味と、どこで使うか

この設計と相性がよいのが、kintone API への実アクセスを発生させない no-op client です。検証や EXPLAIN のような処理では、外部書き込みなしに解析結果だけを得たい場面があります。

ここで区別すべきなのは、構文・意味解析と、実行時に実クライアントが必要な文です。

  • validateksql_explain は、kintone API を呼ばずに完結する
  • ksql_query でも FROM の無い SELECTisNoFromSelectStatement(...) で判定して no-op client で実行できる
  • 一方、FROM を持つ参照や実データに触れる実行では runtime / client の生成が必要になる

no-op client は、外部副作用なしに解析・一部実行するための仕組みです。すべての query を no-op で済ませるためのものではありません。

dmlMaxRows は本論3で文種別ごとに効く

件数上限は文種別で効き方が異なります。INSERT ... VALUES では insertValuesCount を使った静的比較、UPDATE / DELETE では実行時の confirm(count, operation) コールバックによる上限判定です。INSERT_SELECT / UPSERT_SELECT は現状未対応として拒否されます。詳細は本論3でまとめて扱います。

validate-first は「実行前に必ず解析する」という意味です。UPDATE / DELETE の件数上限については、validate の結果だけで完結させず、実行時の confirm コールバックでも止めます。

本論3:DML ガード ― 変更系(DML)操作は「明示的承認」がなければ通さない

3本目の柱は、ksql_mutate に対する 多層 DML ガード です。

変更系(DML)操作は、単に DML だから許可で終わりません。本実装では、少なくとも次の入力を要求します。

  • allowDml
  • confirmText
  • dmlMaxRows

これは、更新系を実行する意思を曖昧な自然言語ではなく明示的な入力として要求する設計です。

スキーマで要求し、実行時にも再アサートする

この条件は src/mcp/schemas.ts の Zod スキーマで定義されるだけではありません。src/mcp/tools.tsmutate 冒頭でも requireDmlApproval(input, "ksql_mutate") が同じ条件を再確認します。

この二重化には理由があります。

  • LLM の出力ゆらぎに備える
  • 呼び出し経路が複数あっても止まるようにする
  • スキーマ通過後の実行文脈でも安全条件を再確認する

スキーマ検証だけに依存しないのが、この構成です。

confirmText をどう扱うか

confirmText は、安全確認を曖昧な自然言語から切り離すための入力です。本実装では次のように扱います。

  • スキーマでは z.literal("yes") を要求する
  • 実行時も requireDmlApproval(...)"yes" の完全一致を再確認する
  • trim()toLowerCase() のような正規化は行わない

確認の存在を曖昧にしないため、受け付け段階でも実行直前でも、要求する値は同じ "yes" に固定されています。

dmlMaxRows は必須として扱う

dmlMaxRows は、DML を受け付けるなら必須にすべき入力です。

  • スキーマで z.number().int().positive() を要求する
  • 実行時ガードでも正の整数であることを再確認する

件数上限の効き方は文種別ごとに異なります。ここを 1 箇所にまとめて押さえておくと、mutateTool の分岐が読みやすくなります。

文種別 dmlMaxRows の効き方
INSERT ... VALUES validate が返す insertValuesCount を静的に比較する
UPDATE / DELETE execute(...) に渡す confirm(count, operation) コールバックで影響行数を受け、上限超過なら拒否する
INSERT_SELECT / UPSERT_SELECT 現状は ksql_mutate が未対応として拒否する

confirm hook は何を指すか

ここでいう confirm hook は、クライアント依存の人間確認 UI ではありません。ksql_mutate がサーバー内で execute(...) に渡すコールバックです。

  • confirm(count, operation) を受け取る
  • count > dmlMaxRows なら throw して拒否する
  • そうでなければ true を返して続行する

UPDATE / DELETE の上限判定は、このフックで実行時に止めます。事前見積もりだけで済ませているわけではありません。

DML ガードの判定連鎖

このフローでは、複数の関門を直列に置いていることが分かります。

mutateTool の抜粋と対応づけると、流れは次のように読めます。まず requireDmlApproval(...) で明示的承認を確認し、その後に validate(...) で文種別を確定します。続いて isDml、未対応文種別、WHERE 必須、INSERT の静的件数を順に見て、最後に execute(...) 側の confirm フックで UPDATE / DELETE の影響行数上限を止めます。

スキーマと実行時ガードの役割分担

実装細部は SDK のバージョンや実コードに依存しますが、役割分担は次のように捉えると整理しやすくなります。後段の index.ts 例と参照名を揃えるため、ここでは実際の識別子に合わせて ...InputSchema...InputShape を使います。

// src/mcp/schemas.ts
import { z } from "zod";

export const queryInputSchema = z.object({
  sql: z.string().min(1),
});

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

export const queryInputShape = queryInputSchema.shape;
export const mutateInputShape = mutateInputSchema.shape;
// src/node/dmlGuard.ts
export function getStatementType(/* ... */) {}
export function isDmlType(/* ... */) {}
export function isReadOnlyType(/* ... */) {}
export function hasWhereClause(/* ... */) {}
export function isNoFromSelectStatement(/* ... */) {}
export function getInsertValuesCount(/* ... */) {}
export function collectDmlTargetFields(/* ... */) {}
export function isMutationStatement(/* ... */) {}

src/node/dmlGuard.ts に置かれているのは、文種別の分類・判定ヘルパーです。明示的承認チェックはここではなく、src/mcp/tools.tsrequireDmlApproval(...) が担います。

この並びから、入力スキーマが受け付ける形を定義し、実行時ガードが最後の防波堤を担っていることが分かります。二重化の実体は次の 2 点です。

  • スキーマ: mutateInputSchemaz.literal(true) / z.literal("yes") / z.number().int().positive()
  • 実行時: mutate 冒頭の requireDmlApproval(input, "ksql_mutate")

業務データを扱う前提では、書き込み到達条件を明示的にし、スキーマ検証と実行時アサートを重ねる構成が理にかないます。使い勝手よりも、曖昧さを残さない入力設計を優先していると読むのが適切です。

本論4:層の地図(責務境界)と以降の予告

ここまでの安全設計は、単一ファイルに押し込まれているわけではありません。理解のためには、mcp → node → core → 解析系 の依存関係を見る必要があります。

各層の責務

まず責務を整理します。

主な責務
mcp ツール登録、stdio 起動、入力スキーマ、MCP 向け結果整形、保存 SQL カタログの公開
node runtime、KintoneClient 構築、APP@profile 正規化、DML 文種別判定、設定読み込み
core execute を入口とする SQL 実行本体
解析系 parseSqlStatement 以下の lexer / parser / converter / engine

MCP 層はあくまで薄い窓口です。SQL の意味解釈や実行そのものは core 以下にあります。その手前で node 層が実行文脈や安全判定を担当します。

依存関係の見取り図

この図は、一方向依存で責務境界を切っていることを示しています。入力は mcp で受け、安全判定や実行文脈の整備は node、SQL 解釈と実行は core 以下です。

本シリーズで読むソースと、各回がどこを扱うかを先に一覧にしておきます(題材は固定コミット 266b595 時点)。

解説対象ソースと担当回

ファイル 役割 担当回
src/mcp/index.ts MCP サーバー起動(stdio)と 11 ツールの登録 第1回・第2回
src/mcp/schemas.ts Zod による各ツールの入力スキーマ 第2回
src/mcp/tools.ts validate を起点にした検証・実行・結果整形・例外境界 第4回・第5回
src/mcp/savedQueries.ts 保存 SQL カタログ(ファイル台帳と安全ルール) 第7回
src/node/dmlGuard.ts AST の文種別判定(read-only / 変更系(DML) の分類) 第4回
src/node/runtime.ts 実行直前の接続文脈(KsqlRuntime)と KintoneClient 構築 第6回
src/node/appProfiles.ts APP@profile の正規化と仮想アプリ ID への張り替え 第6回
src/node/config.ts 設定ファイル読込と環境変数ヘルパ 第6回
src/core/sql.tssrc/core/index.ts parseSqlStatement(Lexer→Parser)と core 公開 API 第3回
src/lexersrc/parsersrc/convertersrc/enginesrc/execute.ts kSQL の字句解析・構文解析・kintone API への変換・実行(execute 第3回
build-mcp.mjsbuild-mcpb.mjspackage.json esbuild バンドル・MCPB 生成・配布設定 第8回

この一覧からも、MCP 層である src/mcp は薄い公開窓口であり、実体は src/nodesrc/core 以下へ委譲される構造だと分かります。以降の各回は、この責務分割に沿ってコードを追っていきます。

index.ts は配線盤として読む

src/mcp/index.ts は、McpServer に複数のツールを登録し、StdioServerTransport で接続するファイルです。ここを業務ロジック本体として読むと混乱します。役割は配線盤です。

  • どのツール名で公開するか
  • どの入力スキーマを使うか
  • どのハンドラに配線するか
  • どの transport でサーバーを起動するか

以降のロードマップ

本シリーズでは、ここから次の順で掘り下げる想定です。

主題
第2回 schemas.tsindex.ts
第3回 解析パイプライン
第4回 実行コア
第5回 DML ガード
第6回 runtime と profile
第7回 保存 SQL カタログ
第8回 ビルドとパッケージング

本稿の地図は、この後のコード読解で「どこを見ているのか」を見失わないための前提になります。

コードで見る最小骨格:index.ts はツールを束ねる配線盤

最後に、src/mcp/index.ts の役割を最小骨格で確認します。ここで重要なのは、index.ts が実行ロジックではなく、公開境界を定義する配線コードだという点です。

実装では、ツール関数を直接ばらで import するのではなく、createKsqlMcpTools({ configPath, profile }) ファクトリでまとめて生成し、その戻り値を registerTool に渡します。また、registerTool のメタデータには title も含まれます。

// src/mcp/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createKsqlMcpTools } from "./tools.js";
import { queryInputShape, mutateInputShape } from "./schemas.js";

async function main() {
  const server = new McpServer({
    name: "ksql-mcp",
    version: "1.0.0",
  });

  const tools = createKsqlMcpTools({
    configPath: process.env.KSQL_CONFIG,
    profile: process.env.KSQL_PROFILE,
  });

  server.registerTool(
    "ksql_query",
    {
      title: "Run read-only kSQL",
      description: "Run read-only kSQL statements",
      inputSchema: queryInputShape,
    },
    tools.queryTool,
  );

  server.registerTool(
    "ksql_mutate",
    {
      title: "Run DML kSQL",
      description: "Run DML kSQL with explicit confirmation and row limits",
      inputSchema: mutateInputShape,
    },
    tools.mutateTool,
  );

  const transport = new StdioServerTransport();
  await server.connect(transport);
}

void main();

本記事のコード例は、題材リポジトリが使う v1 系 @modelcontextprotocol/sdk を前提としています。2026-06 時点では latest が 1.29.0 で、公式も当面は v1.x を本番推奨としています。一方で v2 系では @modelcontextprotocol/server / @modelcontextprotocol/client への分割が進んでいますが、同時点では 2.0.0-alpha 段階のプレリリースです。したがって新規実装では、公式の最新ドキュメントでその時点の推奨パッケージ構成を確認するのがよいでしょう。

この形は、MCP サーバー設計の再利用可能なパターンです。

  • ツール名を決める
  • 入力スキーマを結びつける
  • ファクトリが返すハンドラに配線する
  • transport で公開する

ここでは main 関数でラップしています。最小骨格として top-level await の前提を持ち込まないためです。version は実装上 1.0.0 のリテラルが渡されています。

11 個すべてをここで展開しなくても、構造理解には十分です。詳細は各回で個別に追えばよく、まずは index.ts を「外向きの公開面」として読むのが適切です。

まとめ:安全設計の3本柱は、AI 連携の一般解にもなる

本記事の要点は、次の3本柱に整理できます。

  1. read-only と DML のツール分離
  2. validate-first
  3. 明示的承認つきの多層 DML ガード

kSQL MCP サーバーの核は、AI に kintone を自由に触らせることではありません。安全な境界をどう設計するかにあります。

  • 能力を最小化する
  • 実行前に検証する
  • 多層防御で止める

この発想は kSQL 固有の技巧ではなく、AI に業務システムを触らせる MCP サーバー全般に応用できる設計原則です。次回(第2回)は、公開境界を形づくる schemas.tsindex.ts を読みます。入力スキーマがどこまで安全条件を担い、index.ts がどのようにツールを MCP に公開するか――その配線を具体的に追います。

参考

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?