データベースやストレージを参照して回答する AI クライアント Node.js サーバをつくる(OpenAI x Supabase)

データベースやストレージを参照して回答する AI クライアント Node.js サーバをつくる(OpenAI x Supabase)

本記事では以下の機能を作成します。

  • ユーザは AI へのプロンプトを HTTP リクエストとしてサーバに送る
  • サーバが受け取ったプロンプトをもとに AI が回答を生成する
  • AI が回答を生成する際にサーバはのデータベースやストレージを参照する
  • ユーザは AI の回答を HTTP レスポンスとして受け取る

なお、AI クライアントは OpenAI のモデルを使用し、データベースやストレージは Supabase を使用します。

では、段階を追って実装していきましょう。

CLI で OpenAI API を呼び出すコードを実装する #

以下のファイルを作成しましょう。

main.ts

import { stdin, stdout } from "node:process";
import readline from "node:readline/promises";
import OpenAI from "openai";

const OPENAI_MODEL = "gpt-5-nano";

// TODO: Set your OpenAI API key here
const OPENAI_API_KEY = "sk-****";

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

async function main(): Promise<void> {
  const prompt = await readUserPrompt();
  if (!prompt) {
    process.exit(1);
  }

  const openAi = new OpenAI({ apiKey: OPENAI_API_KEY });
  const response = await openAi.responses.create({
    model: OPENAI_MODEL,
    input: prompt,
  });

  console.log(response.output_text);
}

async function readUserPrompt(): Promise<string> {
  const reader = readline.createInterface({ input: stdin, output: stdout });
  try {
    return (await reader.question("Prompt: ")).trim();
  } finally {
    reader.close();
  }
}

package.json

{
  "private": true,
  "type": "module",
  "dependencies": {
    "openai": "^6.22.0"
  },
  "devDependencies": {
    "@types/node": "^25.3.0"
  }
}

ファイルが用意できたら node main.ts と入力、実行して、プロンプトを入力してみましょう。OpenAI API を呼び出して回答が返ってくるはずです。

Terminal

Supabase の MCP サーバを設定する #

Supabase の MCP サーバの設定ページを開きます。

https://supabase.com/docs/guides/getting-started/mcp

プロジェクトを選択し、読み取り/書き込みの権限、許可する機能を選びます。生成された URL をコピーします。

Supabase

Codex、Claude Code や VS Code、Cursor で使用する場合は対話的に認証処理が行われるためアクセストークンは不要ですが、今回のように非対話環境で使用する場合は事前にアクセストークンが必要になります。

Supabase

Supabase の設定ページからパーソナルアクセストークンを作成し、コピーしておきましょう。

https://supabase.com/dashboard/account/tokens

Supabase

main.ts を以下のように編集して、Supabase MCP サーバの URL とパーソナルアクセストークンを設定しましょう。

import { stdin, stdout } from "node:process";
import readline from "node:readline/promises";
import OpenAI from "openai";
import type { Tool } from "openai/resources/responses/responses";

const OPENAI_MODEL = "gpt-5-nano";

// TODO: Set your OpenAI API key here
const OPENAI_API_KEY = "sk-****";

// TODO: Set your Supabase MCP server URL here
const SUPABASE_MCP_SERVER_URL =
  "https://mcp.supabase.com/mcp?project_ref=********************&read_only=true&features=database%2Cstorage";

// TODO: Set your Supabase personal access token here
const SUPABASE_MCP_BEARER_TOKEN = "sbp_****";

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

async function main(): Promise<void> {
  const prompt = await readUserPrompt();
  if (!prompt) {
    process.exit(1);
  }

  const openAi = new OpenAI({ apiKey: OPENAI_API_KEY });
  const tools: Array<Tool> = [];

  tools.push({
    type: "mcp",
    server_url: SUPABASE_MCP_SERVER_URL,
    server_label: "supabase",
    server_description: "Supabase project resources and tools",
    require_approval: "never",
    headers: {
      Authorization: `Bearer ${SUPABASE_MCP_BEARER_TOKEN}`,
    },
  });

  const response = await openAi.responses.create({
    model: OPENAI_MODEL,
    input: prompt,
    tool_choice: "auto",
    tools,
  });

  console.log(response.output_text);
}

async function readUserPrompt(): Promise<string> {
  const reader = readline.createInterface({ input: stdin, output: stdout });
  try {
    return (await reader.question("Prompt: ")).trim();
  } finally {
    reader.close();
  }
}

では node main.ts を実行して、プロンプトを入力してみます。MCP を通じて Supabase にアクセスできていることが分かります。

Terminal

Supabase のデータベースにテーブルを用意して参照させる #

さきほど試したプロンプトを試したとき、Supabase はプロジェクトを作ったばかりの状態だったため、データベースもストレージも空でした。AI クライアントがデータベースやストレージの内容に基づいて回答できるように、Supabase にデータを用意します。

Supabase の SQL エディタを開いて、以下の SQL クエリを実行します。

Supabase

create extension if not exists pgcrypto;

insert into storage.buckets (id, name, public)
values ('diaries', 'diaries', false)
on conflict (id) do nothing;

create table if not exists public.diaries (
  id uuid primary key default gen_random_uuid(),
  title text not null,
  created_at timestamptz not null,
  file_path text not null unique
);

insert into public.diaries (title, created_at, file_path)
values
  ('朝の散歩メモ',         '2026-01-01T09:00:00+09:00', 'diaries/20260101/diary-1.txt'),
  ('新しいカフェに行った', '2026-01-01T21:00:00+09:00', 'diaries/20260101/diary-2.txt'),
  ('雨の日の作業ログ',     '2026-01-05T14:00:00+09:00', 'diaries/20260105/diary-1.txt'),
  ('読書メモその1',       '2026-01-05T22:00:00+09:00', 'diaries/20260105/diary-2.txt'),
  ('読書メモその2',       '2026-01-20T19:00:00+09:00', 'diaries/20260120/diary-1.txt'),
  ('買い物と夕食',         '2026-02-01T18:00:00+09:00', 'diaries/20260201/diary-1.txt'),
  ('仕事のふりかえり',     '2026-02-02T23:00:00+09:00', 'diaries/20260202/diary-1.txt'),
  ('週末の映画感想',       '2026-02-03T21:00:00+09:00', 'diaries/20260203/diary-1.txt'),
  ('運動記録',             '2026-02-04T20:00:00+09:00', 'diaries/20260204/diary-1.txt'),
  ('読書メモその3',       '2026-02-05T19:00:00+09:00', 'diaries/20260205/diary-1.txt')
on conflict (file_path) do nothing;

実行後に確認すると、テーブルが作成され、レコードが挿入されていることが分かります。

Supabase

ストレージも作成されていますが、まだ中身は空っぽです。

とりあえず、この時点で node main.ts を実行してみます。テーブルにはアクセスできていますね。

Terminal

Supabase のストレージにファイルを用意して参照させる #

ストレージには日記本文を意味するテキストファイルをアップロードします。

Supabase

diaries/20260101/diary-1.txt
朝の散歩に出たら、空気が澄んでいて気持ちよかった。
公園の木にはまだ冬の冷たさが残っていて、手が少しかじかんだ。
コンビニで温かいコーヒーを買って帰宅。
新年の一日目を静かに始められて、いいスタートになった。
diaries/20260101/diary-2.txt
気になっていた駅前の新しいカフェに行ってみた。
店内は落ち着いた雰囲気で、席の間隔も広くて居心地がいい。
カフェラテとチーズケーキを注文したが、どちらも当たりだった。
次はノートPCを持って、作業しに来てもよさそう。
diaries/20260105/diary-1.txt
朝から雨で外出をやめて、家で作業を進めた。
やることを小さく分けて書き出したら、思ったより進んだ。
午後は集中力が落ちたので、短い休憩を何回か入れた。
天気は悪かったけれど、結果的には良い一日だった。
diaries/20260105/diary-2.txt
読書メモその1。
今日は序盤の章を読んだ。導入部分なのに、テーマがはっきりしていて読みやすい。
気になったのは「習慣は環境に強く影響される」という考え方。
明日はこの章の要点を自分の生活にどう当てはめるか考えてみたい。
diaries/20260120/diary-1.txt
読書メモその2。
中盤に入って、具体例が増えてきて理解しやすくなった。
特に、記録を残すことで改善しやすくなるという話が印象に残った。
読みっぱなしにせず、実際に1週間だけでも試してみる価値がありそう。
diaries/20260201/diary-1.txt
仕事帰りにスーパーで買い物をして、家で簡単に夕食を作った。
野菜を多めに使ったスープと、焼いた魚で満足感のある献立になった。
外食より落ち着けるし、食費も抑えられてよかった。
今週はこの流れで自炊の回数を増やしたい。
diaries/20260202/diary-1.txt
今日の仕事を振り返る。
午前中に優先度の高いタスクを片付けられたのはよかった。
一方で、細かい確認作業に時間を使いすぎた気がする。
明日は最初に「今日やらないこと」も決めてから進めてみる。
diaries/20260203/diary-1.txt
週末に見た映画の感想メモ。
派手な展開よりも、登場人物の会話でじわじわ引き込まれるタイプの作品だった。
終盤の演出が静かで、見終わったあとに余韻が残る。
もう一度見たら、前半の伏線にもっと気づけそう。
diaries/20260204/diary-1.txt
運動記録。
軽くストレッチをしてから、30分ほどウォーキングをした。
最初は体が重かったが、後半はペースが安定して気分もすっきり。
無理をしない範囲で、まずは継続を目標にする。
diaries/20260205/diary-1.txt
読書メモその3。
終盤まで読み進めて、最初に感じた印象とつながる部分が多かった。
抽象的な話だけでなく、日常で試せる工夫がいくつも紹介されていて実践しやすい。
読み終えたら、印象に残ったポイントを3つに絞ってまとめる予定。

ここまで用意できたら、node main.ts を実行してみたいのですが、実はこの時点ではまだ AI クライアントはテキストファイルの内容を参照できません。

Supabase の MCP 設定時に Storage への権限も付与したものの、そこで付与できるのはストレージのバケット全体へのアクセス権限であって、個々のファイルへのアクセス権限ではないためです。(これは本記事作成時点の内容なので、将来は変更される可能性もあります。)

Supabase

ストレージのファイルにアクセスするためには、自分で Supabase のストレージ API を呼び出して、ファイルの内容を取得するコードを実装する必要があります。

Supabase のプロジェクトのページからプロジェクト URL やシークレットキーをコピーします。

Supabase

ファイルを以下のように編集して、Supabase のストレージ API を呼び出すコードを追加しましょう。

package.json

{
  "private": true,
  "type": "module",
  "dependencies": {
    "@supabase/supabase-js": "^2.97.0",
    "openai": "^6.22.0"
  },
  "devDependencies": {
    "@types/node": "^25.3.0"
  }
}

main.ts

import type { SupabaseClient } from "@supabase/supabase-js";
import { createClient } from "@supabase/supabase-js";
import { stdin, stdout } from "node:process";
import readline from "node:readline/promises";
import OpenAI from "openai";
import type {
  ResponseFunctionToolCall,
  Tool,
} from "openai/resources/responses/responses";

const OPENAI_MODEL = "gpt-5-nano";

// TODO: Set your OpenAI API key here
const OPENAI_API_KEY = "sk-****";

// TODO: Set your Supabase MCP server URL here
const SUPABASE_MCP_SERVER_URL =
  "https://mcp.supabase.com/mcp?project_ref=********************&read_only=true&features=database%2Cstorage";

// TODO: Set your Supabase personal access token here
const SUPABASE_MCP_BEARER_TOKEN = "sbp_****";

// TODO: Set your Supabase project URL here
const SUPABASE_PROJECT_URL = "https://********************.supabase.co";

// TODO: Set your Supabase secret key here
const SUPABASE_SECRET_KEY = "sb_secret_*******************************";

const DIARY_STORAGE_BUCKET = "diaries";

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

async function main(): Promise<void> {
  const prompt = await readUserPrompt();
  if (!prompt) {
    process.exit(1);
  }

  const openAi = new OpenAI({ apiKey: OPENAI_API_KEY });
  const supabase = createClient(SUPABASE_PROJECT_URL, SUPABASE_SECRET_KEY);
  const tools: Array<Tool> = [];

  tools.push({
    type: "mcp",
    server_url: SUPABASE_MCP_SERVER_URL,
    server_label: "supabase",
    server_description: "Supabase project resources and tools",
    require_approval: "never",
    headers: {
      Authorization: `Bearer ${SUPABASE_MCP_BEARER_TOKEN}`,
    },
  });
  tools.push({
    type: "function",
    name: "read_diary_file",
    description:
      "Read diary text from Supabase Storage using a file path from public.diaries.file_path. Returns the file content as plain text.",
    strict: false,
    parameters: {
      type: "object",
      additionalProperties: false,
      properties: {
        file_path: {
          type: "string",
          description: "Path stored in public.diaries.file_path",
        },
        bucket: {
          type: "string",
          description: "Optional bucket name. Defaults to 'diaries'.",
        },
      },
      required: ["file_path"],
    },
  });

  let response = await openAi.responses.create({
    model: OPENAI_MODEL,
    input: prompt,
    tool_choice: "auto",
    tools,
  });

  for (let i = 0; i < 8; i += 1) {
    const functionCalls = response.output.filter(
      (item): item is ResponseFunctionToolCall => item.type === "function_call",
    );

    if (functionCalls.length === 0) {
      break;
    }

    const toolOutputs = await Promise.all(
      functionCalls.map((call) => runLocalFunctionTool(call, supabase)),
    );

    response = await openAi.responses.create({
      model: OPENAI_MODEL,
      previous_response_id: response.id,
      input: toolOutputs,
      tool_choice: "auto",
      tools,
    });
  }

  console.log(response.output_text);
}

async function readUserPrompt(): Promise<string> {
  const reader = readline.createInterface({ input: stdin, output: stdout });
  try {
    return (await reader.question("Prompt: ")).trim();
  } finally {
    reader.close();
  }
}

async function runLocalFunctionTool(
  call: ResponseFunctionToolCall,
  supabase: SupabaseClient,
) {
  if (call.name !== "read_diary_file") {
    return {
      type: "function_call_output" as const,
      call_id: call.call_id,
      output: JSON.stringify({ error: `Unknown function: ${call.name}` }),
    };
  }

  try {
    const args = JSON.parse(call.arguments) as {
      file_path?: string;
      bucket?: string;
    };
    const text = await readDiaryFileContent(supabase, args);

    return {
      type: "function_call_output" as const,
      call_id: call.call_id,
      output: text,
    };
  } catch (error) {
    return {
      type: "function_call_output" as const,
      call_id: call.call_id,
      output: JSON.stringify({
        error: error instanceof Error ? error.message : String(error),
      }),
    };
  }
}

async function readDiaryFileContent(
  supabase: SupabaseClient,
  args: { file_path?: string; bucket?: string },
): Promise<string> {
  const filePath = args.file_path?.trim();
  if (!filePath) {
    throw new Error("file_path is required");
  }
  const bucket = (args.bucket || DIARY_STORAGE_BUCKET).trim();
  const bucketPrefix = `${bucket}/`;
  const normalizedPath = filePath.startsWith(bucketPrefix)
    ? filePath.slice(bucketPrefix.length)
    : filePath;
  const { data, error } = await supabase.storage
    .from(bucket)
    .download(normalizedPath);
  if (error) {
    throw new Error(`Storage download failed: ${error.message}`);
  }
  const text = await data.text();
  return text;
}

node main.ts を実行してみましょう。AI クライアントがストレージのファイルを参照して回答を生成していることがわかります。

Terminal

Terminal

API サーバとして実装する #

これまでは CLI でプロンプトを入力していましたが、これを HTTP API サーバとして実装してみましょう。main.ts を以下のように編集します。

import type { SupabaseClient } from "@supabase/supabase-js";
import { createClient } from "@supabase/supabase-js";
import { createServer } from "node:http";
import OpenAI from "openai";
import type {
  ResponseFunctionToolCall,
  Tool,
} from "openai/resources/responses/responses";

const OPENAI_MODEL = "gpt-5-nano";

// TODO: Set your OpenAI API key here
const OPENAI_API_KEY = "sk-****";

// TODO: Set your Supabase MCP server URL here
const SUPABASE_MCP_SERVER_URL =
  "https://mcp.supabase.com/mcp?project_ref=********************&read_only=true&features=database%2Cstorage";

// TODO: Set your Supabase personal access token here
const SUPABASE_MCP_BEARER_TOKEN = "sbp_****";

// TODO: Set your Supabase project URL here
const SUPABASE_PROJECT_URL = "https://********************.supabase.co";

// TODO: Set your Supabase secret key here
const SUPABASE_SECRET_KEY = "sb_secret_*******************************";

const DIARY_STORAGE_BUCKET = "diaries";

const PORT = 3000;

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

async function main(): Promise<void> {
  const openAi = new OpenAI({ apiKey: OPENAI_API_KEY });
  const supabase = createClient(SUPABASE_PROJECT_URL, SUPABASE_SECRET_KEY);
  const server = createServer(async (req, res) => {
    try {
      if (req.method === "GET" && req.url === "/health") {
        return sendJson(res, 200, { ok: true });
      }

      if (req.method !== "POST" || req.url !== "/prompt") {
        return sendJson(res, 404, { error: "Not Found" });
      }

      const body = await readJsonBody(req);
      const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
      if (!prompt) {
        return sendJson(res, 400, { error: "prompt is required" });
      }

      const text = await handlePrompt(prompt, openAi, supabase);
      return sendJson(res, 200, { text });
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      return sendJson(res, 500, { error: message });
    }
  });

  await new Promise<void>((resolve, reject) => {
    server.once("error", reject);
    server.listen(PORT, () => resolve());
  });

  console.log(`Server listening on http://localhost:${PORT}`);
}

async function runLocalFunctionTool(
  call: ResponseFunctionToolCall,
  supabase: SupabaseClient,
) {
  if (call.name !== "read_diary_file") {
    return {
      type: "function_call_output" as const,
      call_id: call.call_id,
      output: JSON.stringify({ error: `Unknown function: ${call.name}` }),
    };
  }

  try {
    const args = JSON.parse(call.arguments) as {
      file_path?: string;
      bucket?: string;
    };
    const text = await readDiaryFileContent(supabase, args);

    return {
      type: "function_call_output" as const,
      call_id: call.call_id,
      output: text,
    };
  } catch (error) {
    return {
      type: "function_call_output" as const,
      call_id: call.call_id,
      output: JSON.stringify({
        error: error instanceof Error ? error.message : String(error),
      }),
    };
  }
}

async function readDiaryFileContent(
  supabase: SupabaseClient,
  args: { file_path?: string; bucket?: string },
): Promise<string> {
  const filePath = args.file_path?.trim();
  if (!filePath) {
    throw new Error("file_path is required");
  }
  const bucket = (args.bucket || DIARY_STORAGE_BUCKET).trim();
  const bucketPrefix = `${bucket}/`;
  const normalizedPath = filePath.startsWith(bucketPrefix)
    ? filePath.slice(bucketPrefix.length)
    : filePath;
  const { data, error } = await supabase.storage
    .from(bucket)
    .download(normalizedPath);
  if (error) {
    throw new Error(`Storage download failed: ${error.message}`);
  }
  const text = await data.text();
  return text;
}

async function handlePrompt(
  prompt: string,
  openAi: OpenAI,
  supabase: SupabaseClient,
): Promise<string> {
  const tools = buildTools();

  let response = await openAi.responses.create({
    model: OPENAI_MODEL,
    input: prompt,
    tool_choice: "auto",
    tools,
  });

  for (let i = 0; i < 8; i += 1) {
    const functionCalls = response.output.filter(
      (item): item is ResponseFunctionToolCall => item.type === "function_call",
    );

    if (functionCalls.length === 0) {
      break;
    }

    const toolOutputs = await Promise.all(
      functionCalls.map((call) => runLocalFunctionTool(call, supabase)),
    );

    response = await openAi.responses.create({
      model: OPENAI_MODEL,
      previous_response_id: response.id,
      input: toolOutputs,
      tool_choice: "auto",
      tools,
    });
  }

  return response.output_text ?? "";
}

function buildTools(): Array<Tool> {
  const tools: Array<Tool> = [];

  tools.push({
    type: "mcp",
    server_url: SUPABASE_MCP_SERVER_URL,
    server_label: "supabase",
    server_description: "Supabase project resources and tools",
    require_approval: "never",
    headers: {
      Authorization: `Bearer ${SUPABASE_MCP_BEARER_TOKEN}`,
    },
  });

  tools.push({
    type: "function",
    name: "read_diary_file",
    description:
      "Read diary text from Supabase Storage using a file path from public.diaries.file_path. Returns the file content as plain text.",
    strict: false,
    parameters: {
      type: "object",
      additionalProperties: false,
      properties: {
        file_path: {
          type: "string",
          description:
            "Path stored in public.diaries.file_path (e.g. diaries/20260205/diary-1.txt or 20260205/diary-1.txt)",
        },
        bucket: {
          type: "string",
          description: "Optional bucket name. Defaults to 'diaries'.",
        },
      },
      required: ["file_path"],
    },
  });

  return tools;
}

function sendJson(
  res: Parameters<ReturnType<typeof createServer>["emit"]>[1] & {
    setHeader(name: string, value: string): void;
    end(chunk?: string): void;
    statusCode: number;
  },
  status: number,
  payload: unknown,
) {
  res.statusCode = status;
  res.setHeader("Content-Type", "application/json; charset=utf-8");
  res.end(JSON.stringify(payload));
}

async function readJsonBody(req: {
  [Symbol.asyncIterator](): AsyncIterableIterator<Buffer>;
}): Promise<Record<string, unknown>> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) {
    chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
  }

  const raw = Buffer.concat(chunks).toString("utf8");
  if (!raw) {
    return {};
  }

  const parsed = JSON.parse(raw) as unknown;
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
    throw new Error("JSON body must be an object");
  }
  return parsed as Record<string, unknown>;
}

node main.ts でサーバを起動した後、curl でリクエストを送ってみます。

curl http://localhost:3000/prompt \
  -X POST \
  -H 'Content-Type: application/json' \
  -d '{"prompt":"2026年2月4日の日記によると、わたしは何分間運動しましたか?"}'

Terminal

ウェブページを作成する #

機能としてはここまでで完成していますが、せっかくなので簡単なウェブページも作成してみましょう。以下のようにファイルを編集します。

main.ts

import type { SupabaseClient } from "@supabase/supabase-js";
import { createClient } from "@supabase/supabase-js";
import { createServer } from "node:http";
import OpenAI from "openai";
import type {
  ResponseFunctionToolCall,
  Tool,
} from "openai/resources/responses/responses";

const OPENAI_MODEL = "gpt-5-nano";

// TODO: Set your OpenAI API key here
const OPENAI_API_KEY = "sk-****";

// TODO: Set your Supabase MCP server URL here
const SUPABASE_MCP_SERVER_URL =
  "https://mcp.supabase.com/mcp?project_ref=********************&read_only=true&features=database%2Cstorage";

// TODO: Set your Supabase personal access token here
const SUPABASE_MCP_BEARER_TOKEN = "sbp_****";

// TODO: Set your Supabase project URL here
const SUPABASE_PROJECT_URL = "https://********************.supabase.co";

// TODO: Set your Supabase secret key here
const SUPABASE_SECRET_KEY = "sb_secret_*******************************";

const DIARY_STORAGE_BUCKET = "diaries";

const PORT = 3000;

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

async function main(): Promise<void> {
  const openAi = new OpenAI({ apiKey: OPENAI_API_KEY });
  const supabase = createClient(SUPABASE_PROJECT_URL, SUPABASE_SECRET_KEY);
  const server = createServer(async (req, res) => {
    try {
      setCorsHeaders(res);

      if (req.method === "OPTIONS" && req.url === "/prompt") {
        res.statusCode = 204;
        res.end();
        return;
      }

      if (req.method === "GET" && req.url === "/health") {
        return sendJson(res, 200, { ok: true });
      }

      if (req.method !== "POST" || req.url !== "/prompt") {
        return sendJson(res, 404, { error: "Not Found" });
      }

      const body = await readJsonBody(req);
      const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
      if (!prompt) {
        return sendJson(res, 400, { error: "prompt is required" });
      }

      const text = await handlePrompt(prompt, openAi, supabase);
      return sendJson(res, 200, { text });
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      return sendJson(res, 500, { error: message });
    }
  });

  await new Promise<void>((resolve, reject) => {
    server.once("error", reject);
    server.listen(PORT, () => resolve());
  });

  console.log(`Server listening on http://localhost:${PORT}`);
}

async function runLocalFunctionTool(
  call: ResponseFunctionToolCall,
  supabase: SupabaseClient,
) {
  if (call.name !== "read_diary_file") {
    return {
      type: "function_call_output" as const,
      call_id: call.call_id,
      output: JSON.stringify({ error: `Unknown function: ${call.name}` }),
    };
  }

  try {
    const args = JSON.parse(call.arguments) as {
      file_path?: string;
      bucket?: string;
    };
    const text = await readDiaryFileContent(supabase, args);

    return {
      type: "function_call_output" as const,
      call_id: call.call_id,
      output: text,
    };
  } catch (error) {
    return {
      type: "function_call_output" as const,
      call_id: call.call_id,
      output: JSON.stringify({
        error: error instanceof Error ? error.message : String(error),
      }),
    };
  }
}

async function readDiaryFileContent(
  supabase: SupabaseClient,
  args: { file_path?: string; bucket?: string },
): Promise<string> {
  const filePath = args.file_path?.trim();
  if (!filePath) {
    throw new Error("file_path is required");
  }
  const bucket = (args.bucket || DIARY_STORAGE_BUCKET).trim();
  const bucketPrefix = `${bucket}/`;
  const normalizedPath = filePath.startsWith(bucketPrefix)
    ? filePath.slice(bucketPrefix.length)
    : filePath;
  const { data, error } = await supabase.storage
    .from(bucket)
    .download(normalizedPath);
  if (error) {
    throw new Error(`Storage download failed: ${error.message}`);
  }
  const text = await data.text();
  return text;
}

async function handlePrompt(
  prompt: string,
  openAi: OpenAI,
  supabase: SupabaseClient,
): Promise<string> {
  const tools = buildTools();

  let response = await openAi.responses.create({
    model: OPENAI_MODEL,
    input: prompt,
    tool_choice: "auto",
    tools,
  });

  for (let i = 0; i < 8; i += 1) {
    const functionCalls = response.output.filter(
      (item): item is ResponseFunctionToolCall => item.type === "function_call",
    );

    if (functionCalls.length === 0) {
      break;
    }

    const toolOutputs = await Promise.all(
      functionCalls.map((call) => runLocalFunctionTool(call, supabase)),
    );

    response = await openAi.responses.create({
      model: OPENAI_MODEL,
      previous_response_id: response.id,
      input: toolOutputs,
      tool_choice: "auto",
      tools,
    });
  }

  return response.output_text ?? "";
}

function buildTools(): Array<Tool> {
  const tools: Array<Tool> = [];

  tools.push({
    type: "mcp",
    server_url: SUPABASE_MCP_SERVER_URL,
    server_label: "supabase",
    server_description: "Supabase project resources and tools",
    require_approval: "never",
    headers: {
      Authorization: `Bearer ${SUPABASE_MCP_BEARER_TOKEN}`,
    },
  });

  tools.push({
    type: "function",
    name: "read_diary_file",
    description:
      "Read diary text from Supabase Storage using a file path from public.diaries.file_path. Returns the file content as plain text.",
    strict: false,
    parameters: {
      type: "object",
      additionalProperties: false,
      properties: {
        file_path: {
          type: "string",
          description:
            "Path stored in public.diaries.file_path (e.g. diaries/20260205/diary-1.txt or 20260205/diary-1.txt)",
        },
        bucket: {
          type: "string",
          description: "Optional bucket name. Defaults to 'diaries'.",
        },
      },
      required: ["file_path"],
    },
  });

  return tools;
}

function sendJson(
  res: Parameters<ReturnType<typeof createServer>["emit"]>[1] & {
    setHeader(name: string, value: string): void;
    end(chunk?: string): void;
    statusCode: number;
  },
  status: number,
  payload: unknown,
) {
  res.statusCode = status;
  setCorsHeaders(res);
  res.setHeader("Content-Type", "application/json; charset=utf-8");
  res.end(JSON.stringify(payload));
}

function setCorsHeaders(
  res: Parameters<ReturnType<typeof createServer>["emit"]>[1] & {
    setHeader(name: string, value: string): void;
  },
) {
  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
}

async function readJsonBody(req: {
  [Symbol.asyncIterator](): AsyncIterableIterator<Buffer>;
}): Promise<Record<string, unknown>> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) {
    chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
  }

  const raw = Buffer.concat(chunks).toString("utf8");
  if (!raw) {
    return {};
  }

  const parsed = JSON.parse(raw) as unknown;
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
    throw new Error("JSON body must be an object");
  }
  return parsed as Record<string, unknown>;
}

index.html

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Diary Prompt Client</title>
    <style>
      :root {
        --bg: #f5f1e8;
        --panel: #fffaf0;
        --ink: #1f1a14;
        --muted: #6f655a;
        --accent: #0f766e;
        --accent-2: #115e59;
        --border: #d8cfc1;
      }

      * {
        box-sizing: border-box;
      }

      body {
        margin: 0;
        min-height: 100vh;
        background:
          radial-gradient(circle at 10% 10%, #efe7d8 0%, transparent 40%),
          radial-gradient(circle at 90% 20%, #e1efe6 0%, transparent 45%),
          var(--bg);
        color: var(--ink);
        font-family: "Hiragino Sans", "Yu Gothic", sans-serif;
        display: grid;
        place-items: center;
        padding: 24px;
      }

      .app {
        width: min(900px, 100%);
        background: color-mix(in srgb, var(--panel) 92%, white);
        border: 1px solid var(--border);
        border-radius: 16px;
        padding: 20px;
        box-shadow: 0 20px 40px rgba(31, 26, 20, 0.08);
      }

      h1 {
        margin: 0 0 8px;
        font-size: 1.4rem;
      }

      .hint {
        margin: 0 0 16px;
        color: var(--muted);
        font-size: 0.95rem;
      }

      textarea {
        width: 100%;
        min-height: 120px;
        resize: vertical;
        border: 1px solid var(--border);
        border-radius: 10px;
        padding: 12px;
        font: inherit;
        background: #fff;
      }

      .row {
        display: flex;
        gap: 12px;
        align-items: center;
        margin-top: 12px;
      }

      button {
        border: none;
        border-radius: 10px;
        padding: 10px 14px;
        background: var(--accent);
        color: white;
        font: inherit;
        cursor: pointer;
      }

      button:hover {
        background: var(--accent-2);
      }

      button:disabled {
        opacity: 0.6;
        cursor: wait;
      }

      #status {
        color: var(--muted);
        font-size: 0.9rem;
      }

      .output {
        margin-top: 16px;
        border: 1px solid var(--border);
        border-radius: 10px;
        background: #fff;
        padding: 14px;
      }

      .output h2 {
        margin: 0 0 8px;
        font-size: 1rem;
      }

      pre {
        margin: 0;
        white-space: pre-wrap;
        word-break: break-word;
        font:
          0.95rem/1.5 ui-monospace,
          SFMono-Regular,
          Menlo,
          monospace;
      }
    </style>
  </head>
  <body>
    <main class="app">
      <h1>Prompt Client</h1>
      <p class="hint">AI に送信するプロンプトを入力してください。</p>

      <label for="promptInput">プロンプト</label>
      <textarea id="promptInput"></textarea>

      <div class="row">
        <button id="sendButton" type="button">送信</button>
        <span id="status"></span>
      </div>

      <section class="output" aria-live="polite">
        <h2>レスポンス</h2>
        <pre id="responseText"></pre>
      </section>
    </main>

    <script>
      const promptInput = document.getElementById("promptInput");
      const sendButton = document.getElementById("sendButton");
      const responseText = document.getElementById("responseText");
      const statusText = document.getElementById("status");

      async function sendPrompt() {
        const prompt = promptInput.value.trim();
        if (!prompt) {
          statusText.textContent = "プロンプトを入力してください。";
          promptInput.focus();
          return;
        }

        sendButton.disabled = true;
        statusText.textContent = "送信中...";
        responseText.textContent = "";

        try {
          const res = await fetch("http://127.0.0.1:3000/prompt", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ prompt }),
          });

          const data = await res.json();
          if (!res.ok) {
            throw new Error(data && data.error ? data.error : "Request failed");
          }

          responseText.textContent =
            typeof data.text === "string" ? data.text : "";
          statusText.textContent = "完了";
        } catch (error) {
          responseText.textContent = "";
          statusText.textContent =
            error instanceof Error
              ? `エラー: ${error.message}`
              : "エラーが発生しました";
        } finally {
          sendButton.disabled = false;
        }
      }

      sendButton.addEventListener("click", sendPrompt);
      promptInput.addEventListener("keydown", (event) => {
        if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
          event.preventDefault();
          void sendPrompt();
        }
      });
    </script>
  </body>
</html>

適当なサーバで index.html を開いて(以下では、VSCode の Live Server で index.html を開いています)、プロンプトを入力して送信してみましょう。

動作していますね。

Web Page