APIキーをクライアントに渡さない!fetchインターセプトで実現するセキュアなAI API配信

この記事は CARTA アドベントカレンダー2025 9日目の記事です。

4行まとめ

  • BizサイドにAPIキーを直接配らずに、MCP Serverとやり取りできるアプリを提供したい…ので!Electronでやってみよう
  • ElectronのMainProcessのglobal.fetchのoverrideで、Gemini APIへのリクエストを透過的にProxyする
  • API側でGeminiのAPIキーを管理し、リクエストを再送する
  • n8nのCustom Nodes便利すぎ

はじめに

こんにちは、サポーターズの ちゅうこ(@y_chu5) です。

CARTA / サポーターズではGoogle Workspaceを使用しています。Google Workspace with Geminiライセンス上では入力が学習に使われず、安心して業務に必要な情報を入力することが出来ます。
ですが、提供されているものだけではGeminiのWebインタフェース上でしかAIを使うことが出来ないため、何かしらのMCP Serverにつなぐといったことが出来ません。

これを解消するには、例えばGitHub Copilotを使いVS CodeからMCP Serverに接続する、またはGemini CLIを使って…というのがあるでしょう。
前者はエンジニアであればそのやり方で十分ですが、CARTAではビジネス職の人には原則としてGitHub Copilotのライセンスを払い出していません。
後者に関してはまたエンジニアであれば黒い画面に発行されたGeminiのライセンスキーを設定して使ってもらう、みたいのも抵抗なく出来ますが、ビジネス職含めそうではない人も多くいます。加えてそのAPIキーの取得や管理を考えると、ユーザ側はどう保管すればいいのかだったり、管理者側は全体の管理で頭を悩ませてしまうでしょう。

多くの職種の人にAIを身近に使ってもらうには、2つのハードルを下げる必要があると思っています。

  • 利用までのハードル
  • APIキーの管理のハードル

これを今回は、MCP Clientとしての機能を持つElectronアプリを配布し、AIサービスへのリクエストをProxyするサーバー経由でAI利用することで解決しようと思います。

ちなみにこれはまだ本番環境での運用はまだしていません!!!!あくまでもPoCとして…
実際のコードは 2つのリポジトリをまたいであるので、ちょっと面倒ですが参考にしてみてください。

今回の主要登場人物

  • Electron
    • 社内向けツールを配る上で、かなり楽に配布できて便利
    • .NET MAUIと悩んだけれども、社内のTypeScript書ける率の高さを見て今回はElectronを採用
  • n8n
    • ワークフロー自動化ツール
    • Webhookを受信する口を作ったり、それを元にWebリクエストを送るみたいなのが比較的ラクに作れる

アーキテクチャ概要

今回のアーキテクチャは下図のようなものです。
以下の図で重要なポイントは3点あります。

  1. クライアント側にはAPI Keyを持たせない
    • SDKには何かしらのAPI Keyっぽいものを渡す必要があるので渡していますが、DummyのAPI Keyを渡しています
  2. SDK自体を改変しない
    • 今回は@google/genaiを使用していますが、内部実装には手を加えていません
    • 変更にも強い(?)形になっているのではないでしょうか
  3. API Keyはn8nで一元管理
    • n8nのCredentials機能を使用して、共通のAPI Keyをエンドポイントを叩ける誰でも使えるようにしています
    • この共通API Keyを使ってCuston NodesからGeminiへのリクエストを再送しています

コード的な実装が絡んでくる、Fetch InterceptorやGeminiRepeaterなどの解説をこれから進めていきます。

コードや仕組みを深ぼる

Fetch Interceptor

今回はElectronアプリのMainProcessで、`global.fetch` のoverrideを行っています。
え、global汚染ですか!?となるかもしれませんが、私が管理できるからいいんです!!!!という強い気持ちで行っていきます。

Geminiへのリクエスト…ではなくて、fetchのリクエストのoverrideを一般化すると、以下のようにして書き換えが可能です。

export type FetchInterceptor = {
  urlPattern: string; // regexとかの方がいいかもしれません
  action: (
    ...args: Parameters<typeof fetch>
  ) => Promise<Parameters<typeof fetch>>;
};

const originalFetch = global.fetch;

export const registerFetchInterceptor = (interceptors: FetchInterceptor[]) => {
  global.fetch = async function (resource, options = {}) {
    const url = resource instanceof Request ? resource.url : resource.toString();

    const interceptor = interceptors.find(
      (i) => url.match(i.urlPattern) !== null
    );
    
    if (interceptor === undefined) {
      return originalFetch(resource, options);
    }

    const [r, o] = await interceptor.action(resource, options);
    return originalFetch(r, o);
  };
};

この様な仕組みを作り、Geminiの場合 https://generativelanguage.googleapis.com/* に対するリクエストを横取りする仕組みを作れば、@google/genai からのリクエストを横取り出来るようになるわけです。
この機構を使って、Proxyサーバーに対してリクエストを送信します。

n8nでのProxy Webhook

n8nで上記の機構から送信するWebhookを作成します。
n8nにはWebhookNodeがあり、ある程度好きにエンドポイントのパスを作成することが出来ます。
ある程度…というように、複雑なパスを設計することが出来ないため、このWebhookのパスは固定で、GeminiのどのPathに対してのリクエストかという表現は、前述したFetch Interceptorで各種ヘッダにパスやメソッドを格納しています。

つまり、FetchInterceptorのaction内で

headers.set("X-ORIGINAL-GEMINI-REQUEST-URL", url);
headers.set("X-ORIGINAL-GEMINI-REQUEST-METHOD", method);

みたいなことをしています。

これにより、後段のNodeに対して

  • どのGemini APIを
  • どのメソッドで
  • どんなBodyで

リクエストをしたいか、というのを渡すことが出来るようになっています。

GeminiRepeaterというn8n CustomNodes

n8nにはCodeNodeがあり、JavaScriptやPythonのコードが実行可能ですが、正直あのエディタ上で不思議な変数を使い回すのは現実的じゃないと思っています。
そのためサポーターズ社内で使用しているn8nインスタンスには、自分でいい感じに作ってるCustomNodesを使用しています。
https://docs.n8n.io/integrations/creating-nodes/overview/

………あれっ、Proxy WebhookもGeminiRepeaterも別にn8nじゃなくても良くないですか?と思ったあなた、鋭いですね。
そうです!!!!!!!今回はCustomNodesという存在も出したいというやつでn8nを選んだという背景があります。めっちゃ叩かれるAPIとかだったらもうちょい良い設計を、しようね(戒め

ですが、この様に簡単にエンドポイントを生やしたり、そのリクエストを元にワークフローを実行する、その結果を返すみたいのが容易に出来るためn8nはとっても、とっても便利なんです。

そのメリットを享受しn8nを使って実装したいという場合は以下のような形で実装できます。
かなり省略したコードなので、実際のコードはGitHubからご確認ください。

export class GeminiRepeater implements INodeType {
    description: INodeTypeDescription = {credentials: [
            {
                /*
                * n8nではGeminiのAPI Keyは以下の名前で登録されています
                */
                name: 'googlePalmApi',

                required: true,
            },
        ],
    };

    async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {const apiKey = (await this.getCredentials<{ apiKey: string }>('googlePalmApi')).apiKey;

        for (let i = 0; i < items.length; i++) {
            const headers = items[i].json.headers as Record<string, string>;

            const url = headers['x-original-gemini-request-url'];
            const method = (
                headers['x-original-gemini-request-method'] || 'GET'
            ).toUpperCase() as IHttpRequestMethods;
            const body = items[i].json.body;

            if (!url.startsWith('https://generativelanguage.googleapis.com/')) {
                throw new NodeOperationError(
                    this.getNode(),
                    'Invalid X-ORIGINAL-GEMINI-REQUEST-URL header',
                );
            }

            // TODO: Logging request sender info for monitoring

            const requestHeaders = {
                'Content-Type': 'application/json',
            };

            const fullResponse = await this.helpers
                .httpRequest({
                    url: url + '?key=' + apiKey,
                    method,
                    headers: requestHeaders,
                    body: method === 'GET' || method === 'HEAD' ? undefined : JSON.stringify(body),
                    returnFullResponse: true,
                });

            const { statusCode, headers: respHeaders, body: respBody } = fullResponse;

            returnData.push({
                json: {
                    statusCode,
                    headers: respHeaders instanceof Map ? Object.fromEntries(respHeaders) : respHeaders,
                    body: respBody,
                },
            });
        }

        return [returnData];
    }
}

ここで重要なのは以下の4点です。

  • Credentialsの取得方法(一敗
  • 意図しないリクエストを送らないように
  • apiKeyはurlに(二敗
  • returnFullResponse: trueを指定(三敗

ここでのハマりどころとしては、Credentialの取得方法、apiKeyの扱い、そしてResponseの取得方法でしょうか。

n8nのCustomNodes内では、`INodeTypeDescription` 内のcredentialsフィールドで指定したcredentialを`this.getCredentials`で取得することが出来ます。
これはn8nのエディタ上で指定したCredentialを取得する方法なので、ぜひ覚えておいてください。

またGeminiに対してはHeaderではなくて、URLのパラメータでapiKeyを渡します。完全に直感に反するのですが(というのも、fetchを横取りした時にHeaderについていた気がするのですが…?)URLに付与します。

そして最後はResponseの取得方法です。`returnFullResponse` を設定しないとBodyだけが返ってきて、透過的なProxyの役割を持てないので、この設定もやっておきましょう。

これでGeminiへのリクエストをいい感じに再送するCustomNodesを作成することが出来ました。

運用する上で考えること

上記の実装でElectronアプリから、Proxyにリクエストを送ることが出来ました。
しかし今のままでは、誰でも叩けてしまいます。

なので実際は以下のようなことを追加で実装するのが必要でしょう。

  • Electron側で認証を何かしら挟んで、IdTokenなり何なりをProxyに投げる
  • 例えばこういう感じで認証をかける
  • 上記施策と組み合わせて、利用量のTracingなり…
  • いい感じのログ…とかとかとか

…うん、面倒ですね!

つまり、我々がやりたかったのってLiteLLMとLangfuseをうまく使うことだったのでは!?

まとめ

上記の取り組みで、Electronアプリ上のglobal.fetchをoverrideし、Geminiへのリクエストを透過的に扱うことが可能となりました。
そのリクエストを元に、Proxyがリクエストを送ることで、クライアント側ではAPI Keyを意識しないという作りも出来ています。

これによって、アプリ利用者に対してはいつも通りにログインしてもらえばいいから、と伝えるだけでGeminiのWebUI以外でもAIを使う手段を提供できるようになるのです。

しかしProxyサーバー側で認証を考えたりとかのやつが増えて大変ですね、というのがあります。
そうつまり、LiteLLMをいい感じに立てたものに対して、@google/genaiのhttpOptionsでbaseUrlを書き換え、独自の認証のIdTokenをHeaderで付与すれば今回のめんどい作業は不要かもしれません。

というように、様々なソリューションを検討し、コスト低く、素早く試せる仕組みを検討していければいいですね!
ちなみに、Electronじゃなくてもやっぱり良くないか…?というのも思っていて、別の仕組みも検討中です。その内容については、また別の機会にお伝えできればと思います(私のGitHubアカウントのRepositoriesを見ると、なるほどね?となるかもしれませんね!)

おまけ

MCP Clientとして…って言ってるけど、ほんまにTool calling出来るんかいなというのはあると思います。
ということで、Electronアプリ上でお天気APIを叩くMCP Serverを立てて検証してみましょう。

export async function setupWeatherMCPServer(): Promise<Client> {
  const server = new McpServer({
    name: "weather-server",
    version: "1.0.0",
  });

  server.registerTool(
    // TODO: Impl
  );
  
 const transports = InMemoryTransport.createLinkedPair();
  await server.connect(transports[0]);

  const client = new Client({
    name: "weather",
    version: "1.0.0",
  });
  client.connect(transports[1]);

  return client;

こんな感じでInMemoryTransportで雑に試せるServerを作成し、そのClientを取得しています。
これをElectronアプリ上で叩いてみましょう。

const weatherClient = await setupWeatherMCPServer();

const response = await ai.models.generateContent({
  model: "gemini-2.0-flash",
  contents:
    "weatherを使って天気を教えて。東京は130010です。結果を日本語で答えて、その天気についての感想をユーモアたっぷりに述べて。",
  config: {
    maxOutputTokens: 10000,
    tools: [mcpToTool(weatherClient)],
    toolConfig: {
      functionCallingConfig: {
        mode: FunctionCallingConfigMode.AUTO,
      },
    },
  },
});

console.log(response.text);

HAHAHA、オモシロイネ