MCP で社内の業務データベースを AI につなぐ:最小構成と権限設計

#MCP#PostgreSQL#TypeScript#Claude Code#権限

青い光に照らされたサーバーの前面
写真:Pexels

社内の業務データベースを AI から使えるようにするなら、MCP サーバーを手元で動かし(stdio)、データベースには読み取りしかできないロールでつなぎ、AI に渡す道具は「何でも SQL を流せる」ものではなく「不具合を期間で引く」のような用途の決まったものに絞る、というのが最小で安全な形です。この記事では、2026年7月28日版の MCP 仕様と TypeScript SDK v2 で、その形を組みます。コードは使い捨ての PostgreSQL 16 に対して実際に動かし、ツールの一覧取得と検索、不正な入力の拒否まで確かめたものです。

MCP とは何か、どこが変わったか

MCP(Model Context Protocol)は、AI のアプリ(Claude Desktop や Claude Code など)と、外部のデータや道具をつなぐための共通の約束事です。データベースごとに AI 向けの連携を作り込まなくても、MCP サーバーを 1 つ作れば、MCP に対応したどのアプリからでも使えます。

2026年10月時点の仕様の最新版は 2026-07-28 です。この版で、接続時の初期化のやり取り(initialize)がなくなり、セッションの考え方も変わりました。それに合わせて SDK も v2 になり、TypeScript ではパッケージが @modelcontextprotocol/sdk から @modelcontextprotocol/server と @modelcontextprotocol/client に分かれ、道具を登録する古い server.tool() は削除されて registerTool() に統一されました。2025年に書かれた解説記事のコードは、そのままでは動かないことがあります。

構成

Claude Code / Claude Desktop(MCP クライアント)
   │  stdio(同じ PC の子プロセス。ネットワークに出ない)
   ▼
MCP サーバー(TypeScript)  … 道具は 2 つだけ:list_tables / query_defects
   │  接続文字列は環境変数から。読み取り専用トランザクション、5 秒で打ち切り
   ▼
PostgreSQL  … mcp_reader ロール(SELECT だけ)

つなぎ方(トランスポート)は、仕様が定める 2 つのうち stdio を使います。MCP のセキュリティの指針も、手元で動かすサーバーは stdio を使って、つなげる相手をその MCP クライアントだけに絞るよう勧めています。

1. 読み取り専用のロールを作る

まずデータベースの側で、AI が使うロールを作ります。例では、生産・品質の表が mes スキーマにあるとします。

CREATE ROLE mcp_reader LOGIN PASSWORD '...' CONNECTION LIMIT 5;
GRANT CONNECT ON DATABASE factory TO mcp_reader;
GRANT USAGE ON SCHEMA mes TO mcp_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA mes TO mcp_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA mes GRANT SELECT ON TABLES TO mcp_reader;
ALTER ROLE mcp_reader SET default_transaction_read_only = on;
ALTER ROLE mcp_reader SET statement_timeout = '5s';

検証したときの結果は次のとおりです。

  • 書き込みは cannot execute INSERT in a read-only transaction で止まる。
  • ただし、セッションの中で SET default_transaction_read_only = off と打てば読み取り専用は外せる。その状態で書き込もうとしても、今度は permission denied for table defects で止まった。
  • pg_sleep(6) は canceling statement due to statement timeout で打ち切られた。

つまり、本当に効いている守りは GRANT SELECT だけを与えた権限で、読み取り専用の既定値は二重の備えです。権限を広く与えて「読み取り専用にしたから大丈夫」とは考えないでください。なお、PostgreSQL の文書のとおり、ALTER DEFAULT PRIVILEGES はこれから作る表にだけ効き、すでにある表には効きません(すでにある表は 3 行目の GRANT で対応しています)。

2. MCP サーバーを書く

mkdir factory-db-mcp && cd factory-db-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod pg
npm install -D tsx typescript @types/node @types/pg

SDK v2 は ES モジュールでだけ配布されているので、type=module が必要です。2026年10月時点の版は @modelcontextprotocol/server 2.3.1、pg 8.23.1 で、Node.js 20 以上が要ります。

src/index.ts:

import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
import { Pool } from 'pg';

// 接続文字列は MCP クライアントの設定(環境変数)から受け取る。AI には渡さない
const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
    application_name: 'mcp-readonly',
    statement_timeout: 5000, // 1文あたり5秒で打ち切る
    max: 2
});

const ALLOWED_SCHEMA = 'mes';
const MAX_ROWS = 200;

async function readOnly<T>(sql: string, params: unknown[] = []): Promise<T[]> {
    const client = await pool.connect();
    try {
        await client.query('BEGIN READ ONLY');
        const res = await client.query(sql, params);
        await client.query('COMMIT');
        return res.rows as T[];
    } catch (e) {
        await client.query('ROLLBACK').catch(() => {});
        throw e;
    } finally {
        client.release();
    }
}

function createServer(): McpServer {
    const server = new McpServer({ name: 'factory-db', version: '0.1.0' });

    server.registerTool(
        'list_tables',
        {
            title: 'List tables',
            description: `List tables and columns in the "${ALLOWED_SCHEMA}" schema`,
            annotations: { readOnlyHint: true, openWorldHint: false }
        },
        async () => {
            const rows = await readOnly(
                `SELECT table_name, column_name, data_type
                   FROM information_schema.columns
                  WHERE table_schema = $1
                  ORDER BY table_name, ordinal_position`,
                [ALLOWED_SCHEMA]
            );
            return { content: [{ type: 'text', text: JSON.stringify(rows) }] };
        }
    );

    server.registerTool(
        'query_defects',
        {
            title: 'Query defects',
            description: 'Defect records by line and date range (max 200 rows)',
            inputSchema: z.object({
                line_code: z.string().max(20).describe('Production line code, e.g. L01'),
                from: z.iso.date().describe('Start date (YYYY-MM-DD)'),
                to: z.iso.date().describe('End date (YYYY-MM-DD), inclusive'),
                limit: z.number().int().min(1).max(MAX_ROWS).default(50)
            }),
            annotations: { readOnlyHint: true, openWorldHint: false }
        },
        async ({ line_code, from, to, limit }) => {
            try {
                const rows = await readOnly(
                    `SELECT d.defect_id, d.detected_at, d.lot_no, d.defect_code, d.qty
                       FROM mes.defects d
                      WHERE d.line_code = $1
                        AND d.detected_at >= $2::date
                        AND d.detected_at <  $3::date + 1
                      ORDER BY d.detected_at DESC
                      LIMIT $4`,
                    [line_code, from, to, limit]
                );
                return { content: [{ type: 'text', text: JSON.stringify(rows) }] };
            } catch (e) {
                return { content: [{ type: 'text', text: `DB error: ${(e as Error).message}` }], isError: true };
            }
        }
    );

    return server;
}

void serveStdio(createServer);
console.error('factory-db MCP server running on stdio');

ポイントは 4 つです。

  • 値は必ず $1 のようなパラメータで渡す。node-postgres の文書は、文字列の連結で SQL を組むと SQL インジェクションにつながると書いています。なお、表名や列名はパラメータにできません。
  • 入力は zod で型と範囲を決める。不正な日付を渡すと、ハンドラが動く前に Invalid ISO date として拒否されることを確かめました。
  • 1 回に返す行数に上限を付ける(ここでは 200 行)。Claude Code は、MCP の道具の出力が 1 万トークンを超えると警告し、既定で 2 万 5 千トークンで打ち切ります。
  • ログは console.error に出す。stdio では標準出力そのものが通信路で、仕様は MCP のメッセージ以外を標準出力に書いてはいけないとしています。SDK の文書も、console.log が 1 行混ざるだけで通信が壊れると注意しています。

3. AI のアプリに登録する

Claude Code では、コマンド 1 つで登録できます。

claude mcp add --transport stdio --env DATABASE_URL=postgres://mcp_reader:...@db.example.local/factory \
  factory-db -- npx tsx /path/to/factory-db-mcp/src/index.ts

-- より後ろは、そのままサーバーの起動コマンドとして渡されます。Claude Code の文書には、--env の直後にサーバー名を書くと、名前も環境変数の指定と解釈されて弾かれる、という注意があります。間に --transport stdio などを挟んでください。登録した後は claude mcp list で確かめられ、作業中は /mcp で状態を見られます。

Claude Desktop では、設定画面の「Developer」から「Edit Config」を開き、claude_desktop_config.json にサーバーを書きます。パスは相対パスではなく、絶対パスで書きます。

{
  "mcpServers": {
    "factory-db": {
      "command": "npx",
      "args": ["tsx", "/Users/you/factory-db-mcp/src/index.ts"],
      "env": { "DATABASE_URL": "postgres://mcp_reader:...@db.example.local/factory" }
    }
  }
}

設定を変えたら、Claude Desktop を完全に終了してから起動し直します。うまく動かないときは、Windows なら %APPDATA%\Claude\logs の mcp-server-factory-db.log に、サーバーが console.error に出した内容が残ります。

登録する前に動きだけ確かめたいときは、公式の検査用の道具が使えます。

npx @modelcontextprotocol/inspector npx tsx src/index.ts

4. 守りの設計

道具の「注釈」は守りではない。readOnlyHint: true を付けると、クライアントが読み取りだけの道具として扱い、確認を省くことがあります。ただし SDK の文書は、注釈は道具の動きを変えないとし、Python SDK の文書もはっきり「ヒントであってセキュリティではない」と書いています。仕様も、信頼できるサーバーから来たもの以外の注釈は信用しないよう、クライアントに求めています。守りは、データベースの権限と、サーバーのコードの側で作ります。

何でも流せる SQL の道具は作らない。AI に任意の SQL を書かせる道具は便利ですが、どの表をどこまで読まれるかを権限だけで制御することになります。上の例のように、用途ごとに道具を作り、引数と行数を絞るほうが、何を読まれうるかを説明できます。

人が止められるようにしておく。仕様は、道具の呼び出しを人が拒否できるようにしておくべきだとしています。使う AI のアプリで、道具を実行する前に確認を求める設定になっているかを確かめてください。

社外の AI サービスにデータが渡ることを忘れない。MCP サーバーは社内で動いていても、道具が返した行は、AI のアプリを通してモデルの提供元に送られます。読ませてよい表と項目を先に決めてください。

5. ネットワーク越しに使う場合

複数の人が共有するサーバーにするなら、もう一つのつなぎ方である Streamable HTTP を使います(以前の HTTP+SSE 方式は 2025年3月の版から非推奨で、新しく採用すべきでないとされています)。この場合、仕様は次のことを求めています。

  • すべての接続で Origin ヘッダーを確かめる(DNS リバインディング攻撃を防ぐため)
  • 手元で動かすなら 127.0.0.1 にだけ待ち受ける
  • すべての接続に認証を付ける

認証の仕組みは仕様の中で任意とされていますが、HTTP で使うなら OAuth 2.1 に沿った方式が定められていて、サーバーは自分宛てに発行されたトークン以外を受け付けてはいけません。stdio で使う場合は、この仕組みではなく環境変数から資格情報を受け取るよう、仕様が書いています。上の例が接続文字列を環境変数で渡しているのは、このためです。

Python で書く場合

Python SDK も v2 になり、FastMCP は MCPServer に名前が変わりました。公式文書の例は次のとおりです(この記事では Python 版は動かしていません)。

from mcp.server import MCPServer
from mcp.types import ToolAnnotations

mcp = MCPServer("Bookshop")


@mcp.tool(
    title="Search the catalog",
    annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False),
)
def search_books(query: str) -> str:
    """Search the catalog by title or author."""
    return f"Found 3 books matching {query!r}."

引数なしの mcp.run() で stdio のサーバーとして起動します。

出典

#MCP#PostgreSQL#TypeScript#Claude Code#権限

執筆weaveAI 編集部(株式会社サオス)

株式会社サオスで weaveAI を開発しているチームです。製造業の業務システムと AI について、一次情報と実際の検証をもとに解説しています。

運営者情報・編集方針 →

weaveAI FactorySuite

御社の工場で、動かしてみませんか。

マスタ・在庫・製造実績・品質・保全をすぐに使え、足りない画面は AI との対話で作れます。いまお使いの帳票(紙・Excel)があれば、どの画面に置き換わるかを一緒に確認します。