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サーバーのコード解説6:複数kintone環境をAPP@profileで切り替える

0
Last updated at Posted at 2026-07-03

本稿が扱うのは、MCP ツールの実行コアから kintone API を実際に叩く直前に挟まるランタイム層です。入力 SQL を受け取り、APP@profile を解決した正規化済み SQLと、上位からは 1 つに見えるclient、さらに**上限値・タイムアウト・cacheContext**を束ねた KsqlRuntime を返します。第4回で見た query / mutate の直後にある層ですが、本稿はその前提がなくても「何を解決する層か」が分かるよう、単体で追える形に絞って説明します。

シリーズ第4回で見た query / mutate の実行コアは、実際に kintone を叩く直前に createKsqlRuntime を呼び、接続文脈を組み立てます。本稿では src/node/runtime.tssrc/node/appProfiles.tssrc/node/config.ts を中心に、ランタイム(KsqlRuntime)が「どの環境の、どのアプリを、どの資格情報で」叩くかをどう解決するかを追います。

狙いは設定値の列挙ではありません。焦点は、接続先・認証・上限・キャッシュ文脈をなぜ runtime 層へ集約するのかです。複数環境、複数認証方式、同一 SQL 内での環境混在という複雑さを createKsqlRuntime が 1 つのランタイムへ封じ込めることで、上位の実行コアは単純なクライアントとして kintone を扱えます。

この層が解く問題

query / mutate の実行コアは、SQL を解釈して終わりではありません。kintone API を呼ぶ段階では、少なくとも次を確定している必要があります。

  • どの profile を使うか
  • その SQL が参照するアプリがどの profile に属するか
  • どの認証方式で接続するか
  • 取得件数やタイムアウトの上限をどうするか
  • フィールド定義などのキャッシュをどの文脈で共有するか

この判断を上位の execute 系ロジックへ散らすと、責務境界が崩れます。validatequerymutate がそれぞれ profile 解決や token 解決を始めると、同じ判断を複数箇所で重複実装することになるからです。

ここで KsqlRuntime は、実行に必要な接続文脈をまとめた構造として機能します。

要素 役割
正規化済み SQL APP@profile を解決し、以後の実行系がそのまま扱える SQL
profileName 既定の接続先として採用された profile 名
client 上位からは 1 つに見える kintone クライアント
cacheContext 接続文脈ごとにキャッシュを分離するためのキー
maxRecords 取得件数上限
onLimit 上限到達時の振る舞い
timeout タイムアウト設定

全体像は次のとおりです。

要点は、環境依存の複雑さが runtime 層で閉じていることです。上位は「SQL と client を受け取って実行する」ことに集中できます。

profile の決定と既定値の優先順位

まずは既定 profile をどう決めるかです。ここでは src/node/runtime.tsresolveDefaultProfile が中心になります(優先順位の各段で参照する環境変数の読み取りは src/node/config.tsenvString などに委ねています)。

設計の要点は、呼び出し側が明示した指定を最優先しつつ、運用上の既定値は設定ファイルへ、デプロイ時の上書きは環境変数へ逃がすことです。

// src/node/runtime.ts
export function resolveDefaultProfile(
  config: KsqlConfig,
  serverOptions?: { profile?: string },
  inputProfile?: string
): string {
  return (
    inputProfile ??
    serverOptions?.profile ??
    process.env.KSQL_PROFILE ??
    config.defaultProfile ??
    "dev"
  );
}
// 実際の環境変数読み取りは config.ts の envString が trim 付きで行います。

この順序は「いろいろ指定できて便利」という話ではありません。誰が何を上書きできるかを明示しています。

  • inputProfile
    • ツール呼び出し時に、その場で切り替えるための指定です
  • serverOptions.profile
    • サーバー起動時オプションとして既定値を与えるための指定です
  • 環境変数 KSQL_PROFILE
    • デプロイ先ごとの差し替えです
  • config.defaultProfile
    • リポジトリとしての標準値です
  • "dev"
    • 最終フォールバックです

maxRecordsonLimittimeout も同じ思想で解決されます。ただし、profile と完全に同一のチェーンとは限りません。実装を読む際は「serverOptions に相当する段があるか」を個別に確認する必要があります。読者がメンタルモデルを揃えやすいよう、ここでは「誰の責任で上書く値か」を軸に整理します。

設定 主な上書き責任者 典型的な解決順序(実装で要確認)
profile 呼び出し元 / サーバー起動者 / 運用者 / 設定管理者 入力 → 起動オプション → 環境変数 → 設定ファイル → 既定値
maxRecords 呼び出し元 / 運用者 / profile 設定 入力 → 環境変数 → profile 設定 → 既定値
onLimit 呼び出し元 / 運用者 / profile 設定 入力 → 環境変数 → profile 設定 → 既定値
timeout 呼び出し元 / 運用者 / profile 設定 入力 → 環境変数 → profile 設定 → 既定値

この設計は、一見すると設定の置き場所が分散しているように見えます。ただし実際には、責任の所在ごとに上書きポイントを分けていると読むべきです。

  • リポジトリ管理者は設定ファイルで既定を定義する
  • 運用者は環境変数でデプロイ差分を与える
  • 呼び出し元は必要な場合だけ明示指定する

代替案として、すべてを 1 つの設定ファイルに閉じ込める方法もあります。しかしその場合、CI/CD や実行環境ごとの差し替えが弱くなります。逆に、すべてを環境変数へ寄せると、構成の見通しが悪くなります。本実装はその中間で、既定はファイル、上書きは env、最終決定は呼び出し時という役割分担を選んでいます。

APP@profile を正規化する理由

次に、この実装で特徴的なのが APP@profile です。たとえば APP100@devAPP100@prod を同一 SQL の中で扱えるようにします。

要するに、SQL 本文側は一意な appId に正規化し、実 appId と profile への復元情報は別マップ appBindingByMappedApp に逃がす、この二層分離が肝です。文字列・コメント・バッククォート識別子の除外や、事前走査による衝突判定、9億番台の仮想 appId 採番は、その分離を安全に成立させるための補助です。

問題は、単に「複数 profile を使える」だけでは足りないことです。同じ実アプリ ID が複数環境に存在しうるため、SQL 内では衝突が起きます。そこで src/node/appProfiles.tsnormalizeSqlAppProfiles が、profile 付きアプリ参照を抽出し、必要に応じて仮想アプリ ID へ張り替えます。

この図で重要なのは、衝突判定を 1 パス中のその場判断で済ませないことです。APP100@dev を先に見た時点では、後続に APP100@prod が出るか分かりません。したがって「衝突する場合だけ仮想 appId を採番する」なら、少なくとも事前走査で衝突集合を求めるか、等価な 2 パス構成が必要です。

ポイントは 2 つです。

  1. SQL を雑な置換で処理しない
  2. 衝突回避のために仮想 appIdを導入する

ここで重要なのは、記事中のコードを実装そのものとして読むのではなく、責務の骨子として読むことです。以下は src/node/appProfiles.ts の考え方を保った簡略化した骨子です。

// src/node/appProfiles.ts の骨子を簡略化した擬似コード
type AppBinding = {
  appId: number;   // 実アプリ ID
  profile: string; // 実行先 profile
};

type NormalizeResult = {
  normalizedSql: string;
  appBindingByMappedApp: Map<number, AppBinding>;
};

export function normalizeSqlAppProfiles(
  sql: string,
  defaultProfile: string
): NormalizeResult {
  const tokens: Array<
    | { kind: "appRef"; raw: string; appId: number; profile?: string }
    | { kind: "text"; raw: string }
  > = [];

  // 1. 左から走査する
  // 2. 文字列リテラル、行コメント、ブロックコメント、バッククォート識別子は
  //    その塊を text として読み飛ばす
  // 3. それ以外の通常領域で APP<number> または APP<number>@<profile> を appRef として拾う
  //    (上記ルールに従い tokens を構築する処理はここでは省略)

  // 4. appRef を事前に集計し、
  //    「同じ実 appId が複数 profile に現れる」衝突集合を求めておく
  //    needsVirtualId はこの事前計算済み集合を参照する前提
  const bindingByReal = new Map<string, number>();
  const appBindingByMappedApp = new Map<number, AppBinding>();
  let nextVirtualAppId = 900_000_000;

  function allocateMappedAppId(appId: number, profile: string): number {
    const key = `${appId}@${profile}`;
    const existing = bindingByReal.get(key);
    if (existing) return existing;

    const mappedAppId = needsVirtualId(appId, profile) // needsVirtualId: 事前走査で求めた衝突集合を参照する(定義は省略)
      ? nextVirtualAppId++
      : appId;

    bindingByReal.set(key, mappedAppId);
    appBindingByMappedApp.set(mappedAppId, { appId, profile });
    return mappedAppId;
  }

  const normalizedSql = tokens
    .map((token) => {
      if (token.kind === "text") return token.raw;
      const profile = token.profile ?? defaultProfile;
      const mappedAppId = allocateMappedAppId(token.appId, profile);
      return `APP${mappedAppId}`;
    })
    .join("");

  return { normalizedSql, appBindingByMappedApp };
}

この needsVirtualId は、その場で未来を予知する関数ではありません。役割は「事前走査で求めた衝突集合を参照して、当該 appId@profile に仮想 appId が必要か」を返すことです。骨子として書く場合でも、この前提を明示しないと 1 パスで完結しているように見え、読者の理解を誤らせます。

固定文字列を replace するだけの実装ではありません。本文が主張したいのは、文字列・コメント・識別子文脈を避けてアプリ参照だけを拾い、SQL 本文側の appId と実行先解決を分離している点です。

執筆時点では、衝突回避のために 9 億番台の仮想 appId を使います。具体的な開始番号は実装値であり、実装では 900,000,000 から採番します。本稿の図や例で用いる APP900000000 などの値は説明用の一例です。重要なのは数値そのものではなく、SQL 本文側では一意な appId に正規化し、復元情報は別マップに保持するという二層構造です。

たとえば次のような差分比較を考えます。

SELECT
  d.$id AS dev_id,
  p.$id AS prod_id,
  d.name AS dev_name,
  p.name AS prod_name
FROM APP100@dev d
JOIN APP100@prod p
  ON d.code = p.code

ここで APP100@devAPP100@prod は、実 appId は同じでも接続先が異なります。上位の SQL 実行系がこの差異を直接意識すると、join 処理や converter が profile 依存になります。そこで runtime 層で仮想化し、上位には1 本の正規化済み SQLとして見せます。

代替案として、SQL AST に profile 情報を直接ぶら下げる方法もありえます。ただしその場合、AST を扱う全レイヤが profile 対応を理解する必要があります。本実装は、SQL テキストと app binding の対応表へ責務を押し込み、既存の実行パスをなるべく壊さない選択をしています。

本稿のコードは設計意図を読むための抜粋です。実際の実装との差分が生じうるため、読者は src/node/appProfiles.ts の実コードでトークナイズ境界と、衝突判定が事前走査か 2 パス構成かを必ず確認してください。

アプリ単位ルーティングする client

APP@profile を正規化しただけでは、まだ実行できません。実際に API を叩く段階では、対象アプリごとに適切な profile の client へ振り分ける必要があります。

ここで src/node/runtime.tscreateKsqlRuntime は、使用される profile ごとに kintone client を構築し、それらを束ねた routed client を返します。

図の読み方は単純です。上位は client を 1 つだけ受け取りますが、内部では mapped appId -> { real appId, profile } を解いてから、profile ごとの実 client へ委譲しています。

上位から見ると client は 1 つですが、その内部では appId ごとに宛先が切り替わります。これがアプリ単位ルーティングです。

以下も src/node/runtime.ts の責務を示す簡略化した骨子です。

// src/node/runtime.ts の骨子を簡略化した擬似コード
type AppBinding = { appId: number; profile: string };

function getClientOrThrow(
  profileClientMap: Map<string, KintoneClient>,
  profile: string
): KintoneClient {
  const client = profileClientMap.get(profile);
  if (!client) {
    // 例外型は擬似コード上の名称。実装では未定義 profile に ArgumentError、資格情報/baseUrl/token 解決失敗に AuthError を投げる
    throw new ConfigError(`Client for profile '${profile}' is not configured`);
  }
  return client;
}

function createRoutedClient(params: {
  defaultProfile: string;
  profileClientMap: Map<string, KintoneClient>;
  appBindingByMappedApp: Map<number, AppBinding>;
}): KintoneClient {
  // 実際の KintoneClient はこれ以外のメソッドも持つ。ここでは routing 対象の抜粋
  const resolveBinding = (mappedAppId: number): AppBinding => {
    return (
      params.appBindingByMappedApp.get(mappedAppId) ?? {
        appId: mappedAppId,
        profile: params.defaultProfile,
      }
    );
  };

  return {
    async getRecords(input) {
      const binding = resolveBinding(input.app);
      const client = getClientOrThrow(params.profileClientMap, binding.profile);
      return client.getRecords({ ...input, app: binding.appId });
    },

    // postRecords / putRecords / deleteRecords / getFields も同型です。
    // いずれも binding を解決し、仮想 appId を実 appId へ戻して、
    // 対応する profile の client へ委譲します。

    async getFields(input) {
      const binding = resolveBinding(input.app);
      const client = getClientOrThrow(params.profileClientMap, binding.profile);
      return client.getFields({ ...input, app: binding.appId });
    },

    async getApps(input) {
      // app 単位 binding を持てない API は defaultProfile に寄せる
      const client = getClientOrThrow(
        params.profileClientMap,
        params.defaultProfile
      );
      return client.getApps(input);
    },
  };
}

ここで getFields は kintone 公式 REST API の正式名そのものではなく、このプロジェクトの KintoneClient ラッパーが提供するメソッド名です。意味としては、kintone 公式の Get Form Fields に相当する操作を routed client から呼べるようにしたものです。

ここでの設計上の主張は、環境の振り分けを上位へ漏らさないことです。

  • execute
  • converter
  • validator の一部

これらは「対象アプリを渡せば結果が返る client」として扱えればよく、profile ごとの切り替えロジックは知る必要がありません。

! で握りつぶさず、意味のある失敗に寄せる

記事として明示しておきたいのは、profileClientMap.get(...)! のような non-null assertion を runtime 境界で安易に使わないことです。設定漏れや未知 profile に遭遇したとき、TypeError では設計意図が読めません。

本層が返すべきなのは「未知の profile で実行しようとした」「その profile の client を構築できていない」という、設定起因の失敗です。したがって、実装を追う際も次のどちらかが保証されているかを確認すべきです。

  • client 構築フェーズで全 profile の存在を検証している
  • routed client 側で ArgumentErrorAuthError に変換している

getApps を default profile へ寄せる理由

getRecordspostRecordsgetFields は app 単位で binding を持てますが、getApps のような操作は特定アプリに紐づきません。そのため、こうした API は default profile の client へ流します。

これは万能ではなく、用途を限定した設計です。getApps の結果が default profile 環境の情報に限定されることは、制約として理解しておく必要があります。複数 profile を跨いだ SQL 実行で本当に必要なのは、多くの場合 app 指定を伴う API です。そのため runtime 層は、アプリ単位で宛先を確定できる操作に重点を置いています。

代替案として、getApps(profile?: string) のように上位へ profile 指定を露出する方法もあります。しかしそれをやると、呼び出し側が「この API は binding で解けるか、明示 profile が要るか」を理解しなければなりません。本実装は、その揺らぎも routed client の内側へ閉じ込めています。

認証情報の解決と token の選択単位

接続先が決まっても、認証情報が解決できなければ実行できません。ここでは profile ごとの認証方式と、token の解決単位が重要です。

認証モードとしては userpasstoken があり、auth: "auto" の場合は username/password があれば userpass、なければ token に解決されます。大事なのは、曖昧なまま接続しないことです。必要な情報が足りなければ、早めに失敗させます。

token 解決は「仮想 appId ではなく、実 appId + profile」で考える

APP@profile の正規化で導入した仮想 appId は、あくまで SQL 本文側の衝突回避です。認証に使う token は仮想 appId に紐づけるべきではありません。token が対応するのは、その profile の実アプリ IDです。

したがって、token 解決の流れは次のようになります。

順序が重要です。まず仮想 appId を実 appId と profile へ戻し、その文脈で token を選びます。これにより APP100@devAPP100@prod が同居しても、同じキーへ衝突せず profile ごとに別 token を使えます。

tokenMap のキー形式は APP<number> で統一する

本稿では tokenMap のキー形式を APP100 のように統一します。profile 修飾をキーへ埋め込むのではなく、profile ごとの設定空間が先に分かれている前提です。

type ProfileConfig = {
  auth: "auto" | "userpass" | "token";
  tokenMap?: Record<string, string>;
};

つまり、同じ APP100 でも profile ごとに独立して持ちます。tokenMap キーにさらに @profile を混ぜる必要はありません。

env: 参照とフォールバックの扱い

以下も責務を示す簡略化した骨子です。

// src/node/runtime.ts の骨子を簡略化した擬似コード
function resolveTokenValue(
  rawValue: string | undefined,
  env: NodeJS.ProcessEnv = process.env
): string | undefined {
  if (!rawValue) return undefined;

  if (rawValue.startsWith("env:")) {
    const envName = rawValue.slice("env:".length);
    const resolved = env[envName];
    if (!resolved) {
      throw new AuthError(`Environment variable '${envName}' is not set`);
    }
    return resolved;
  }

  return rawValue;
}

function resolveAppToken(params: {
  appId: number; // 実 appId
  profileName: string;
  profileTokenMap?: Record<string, string>;
  envTokenMap?: Record<string, string>;
  fallbackToken?: string;
}) {
  const key = `APP${params.appId}`;

  // ここでの ?? は「前の候補が undefined だったら次を見る」という意味です。
  // ただし候補値が env:... で、その環境変数が未設定なら resolveTokenValue が throw します。
  // つまり「候補キー自体が無い」ときだけ次へ進み、
  // 「明示指定した env: が解決できない」ときはその場で失敗します。
  const token =
    resolveTokenValue(params.profileTokenMap?.[key]) ??
    resolveTokenValue(params.envTokenMap?.[key]) ??
    resolveTokenValue(params.fallbackToken);

  if (!token) {
    throw new AuthError(
      `Token is required for profile '${params.profileName}', app '${key}'`
    );
  }

  return token;
}

ここでは 2 点を強調します。

1. 秘密値をコードや配布物へ直書きしない

env:VAR_NAME を許すことで、設定ファイルには参照名だけを置き、実際の token は実行環境から注入できます。これは、リポジトリや配布アーティファクトに秘密値を残しにくくするための判断です。

const profile = {
  auth: "token",
  tokenMap: {
    APP100: "env:KSQL_TOKEN_APP100",
    APP200: "env:KSQL_TOKEN_APP200",
  },
};

2. 明示指定した env: が引けなければ、その場で落とす

env: 指定があるのに対象環境変数が未定義なら、undefined にして次の候補へ進めるべきではありません。それを許すと「明示的にこの secret を使うつもりだったのに、別 token が黙って採用された」という曖昧な補完になります。

見た目だけを追うと ?? で候補を並べているため、「profileTokenMap がだめなら envTokenMap、その次は fallbackToken」と素直にフォールバックするコードに見えます。しかし実際の意味は少し違います。

  • profileTokenMap?.[key] 自体が未定義なら、次の候補へ進みます
  • しかし profileTokenMap?.[key]env:... で、その環境変数が未設定なら、そこで AuthError を投げて終了します
  • 後続候補は「候補が存在しない」場合にだけ参照されます

認証まわりは、便利さより失敗の明示性を優先すべき領域です。誤った token で別環境へ書き込む事故は、単なる設定ミスよりはるかに重いからです。

cacheContext は何を分離するか

認証と並んで重要なのが cacheContext です。buildCacheContext は、validate と実行が同じ接続文脈を共有できるようにするキーを作ります。

考え方は次の図で表せます。

図の焦点は、キャッシュを「処理種別」ではなく「接続文脈」で分ける点です。validate と query が同じ profile・同じ接続先・同じ対象集合を見ているなら共有できますが、そこがずれたら共有してはいけません。

重要なのは、接続先が違えばキャッシュも分けることです。同じ APP100 でも devprod でフィールド定義が一致する保証はありません。もし cache key が appId だけなら、環境をまたいだ誤キャッシュが起きえます。

ここで読者が気にすべきは、cacheContext が何を連結しているかです。少なくとも設計上は、次のような要素を含める必要があります。

要素 含める理由
profile 名 devprod の分離に必要
対象 appId 群 同じ profile でも参照アプリ集合が異なれば文脈が変わるため
appId 群のソート済み表現 順序差だけで別キーにならないようにするため
接続先識別子 同じ profile 名でも baseUrl やテナントが異なる構成を避けるため

簡略化すると、イメージは次のようになります。

// buildCacheContext の考え方を示す擬似コード
function buildCacheContext(input: {
  profileName: string;
  baseUrl: string;
  appIds: number[];
}): string {
  const apps = [...new Set(input.appIds)].sort((a, b) => a - b).join(",");
  return `${input.profileName}|${input.baseUrl}|${apps}`;
}

ここでの論点は、profile 名だけでは不十分になりうることです。たとえば運用上、同じ profile 名でも baseUrl だけ異なる構成を許すなら、profileName 単独では衝突します。そのため、接続先を一意に識別する要素を含めるべきかを実装で確認する必要があります。

第4回で見た validate と実行の連携は、この cacheContext によって接続文脈レベルで整合します。runtime 層がこれを生成することで、上位は「同じ文脈なら同じキャッシュを使える」とだけ理解すれば済みます。

キャッシュの共有は性能最適化ですが、接続文脈をまたいで混線すると正しさを壊します。cacheContext を runtime 層で組み立てるのは、その混線を防ぐためです。

設計の狙いと、採らなかった代替案

ここまでの設計判断をまとめると、runtime 層は次を引き受けています。

  • profile を決定する
  • APP@profile を正規化する
  • 衝突時は仮想アプリ ID を採番する
  • profile ごとの client を構築し、アプリ単位ルーティング client を返す
  • maxRecordsonLimittimeout を確定する
  • token や userpass を解決する
  • cacheContext を生成する

これを 1 つの runtime 層へ集約することで、上位の実行コアは正規化済み SQL と 1 つの client を扱うという単純な形を保てます。複数環境や認証方式の差異を上位へ漏らさないことが、この設計の価値です。

採らなかった代替案も整理しておきます。

代替案 採らなかった理由
SQL AST に profile 情報を埋め込む AST を扱う全レイヤが profile 対応を理解する必要がある
上位 execute 層で profile ごとに client を切り替える 実行コアへ環境依存の分岐が漏れる
token をグローバル 1 個で持つ アプリ単位 token や環境差分を安全に表現しにくい
cache key を appId だけにする 環境を跨いだ誤キャッシュを防げない

要するに、本実装が増やしているのは機能そのものより、複雑さの置き場所の明確さです。接続先、認証、キャッシュ、上限という環境依存の論点は runtime 層へ押し込み、実行コアはクエリ処理に集中させる。この責務分離が、シリーズ全体の設計を支えています。

次回、第7回では、こうしたクエリを再利用する保存 SQL カタログへ進みます。

連載ナビ

参考

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?