コンテンツにスキップ

Slack MCP Agent — Custom Tool 設計

v1.2 / 2026-04-20

ツールカタログ、共通スキーマ、ベンダー抽象化の設計を定義する。
Custom Tool Executor は freee HR 3 ツールの本番実行パスである(docs/architecture.md Section 4.4, 8.2 参照)。
MCP Server hr-freee は開発テスト用。出力系ツール (Google Sheets, Slack DM) は MCP Server common で実行する。
docs/tech-decisions.md ADR-010 (Custom Tool Executor) の詳細化。


1. 設計原則

# 原則 説明
T-1 認証情報は Backend 内で完結 OAuth トークンは Backend プロセスのみが保持。LLM API / MCP Server に渡さない
T-2 ベンダー抽象レイヤーで将来に備える freee 固有型をそのまま返さず、共通スキーマに変換して Agent に提供
T-3 ツールスキーマは JSON Schema Managed Agent が理解できる JSON Schema 形式でツール定義
T-4 MVP は 5 ツール HR 3 ツール + Google Sheets 1 + Slack DM 1
T-5 エラーはテキストで Agent に返す 例外を握りつぶさず、Agent が判断できる形式のエラーメッセージを返す

2. ツールカタログ

2.1 MVP ツール一覧

# ツール名 カテゴリ 実行方式 ベンダー 説明
1 list_employees HR Custom Tool freee 従業員一覧取得
2 list_attendance HR Custom Tool freee 勤怠データ取得
3 calculate_overtime HR Custom Tool freee 残業時間計算
4 write_rows_to_google_sheet Output MCP Server Google スプレッドシート書込
5 send_slack_dm Output MCP Server Slack DM 送信

2.2 実行方式の使い分け

判断基準 Custom Tool (Backend 内) MCP Server (Agent 経由)
認証情報を扱う (freee) 採用
個人情報を処理する 採用
書込・更新系 (Sheet/DM) 採用
参照系のみ 両方可 両方可
監査要件が高い 採用 (audit_logs 統合)

3. Custom Tool Executor アーキテクチャ

3.0 custom_tool_use イベント処理フロー

L4 ポーリングループ(docs/architecture.md Section 4.3)内で agent.custom_tool_use イベントを検知し、Backend 内で処理する。

L4 ポーリングループ
  ├── sessions.events.list()
  │    └── イベント走査
  │         ├── agent.mcp_tool_use        → MCP Server 経由 (common ツール)
  │         └── agent.custom_tool_use     → ★ Backend 内で処理
  ├── agent.custom_tool_use 検知時:
  │    ├── tool_name, tool_input を抽出
  │    ├── handle_custom_tool(tool_name, tool_input, tenant_id, conn)
  │    │    └── Custom Tool Router → freee Adapter → freee API
  │    └── events.send(tool_result) → Managed Agent Session 続行
  └── 次のポーリングへ

Custom Tool Executor が扱うツール: list_employees, list_attendance, calculate_overtime(全て freee HR 系)。 MCP Server common が扱うツール (write_rows_to_google_sheet, send_slack_dm) は agent.mcp_tool_use として処理され、Backend は介入しない。

3.1 全体フロー

flowchart TD
    A["Managed Agent Session\n(Anthropic Cloud)"] -->|"agent.custom_tool_use event\n(tool_name, tool_input)"| B["Backend L4 ポーリングループ"]
    B -->|"_handle_custom_tool()"| C{"Custom Tool Router"}

    C -->|list_employees| D1["freee Adapter\nGET /hr/api/v1/employees\n→ SharedSchema.Employee[]"]
    C -->|list_attendance| D2["freee Adapter\nGET /hr/api/v1/employees/id/work_records\n→ SharedSchema.Attendance[]"]
    C -->|calculate_overtime| D3["freee Adapter\nlist_attendance 集計\n→ SharedSchema.OvertimeSummary"]

    D1 --> E["freee API Client"]
    D2 --> E
    D3 --> E

    E -->|"OAuth Token Manager\nRate Limiter\nError Handler"| F["freee API"]

    D1 --> G["tool_result (JSON)"]
    D2 --> G
    D3 --> G

    G -->|"events.send(tool_result)"| A

3.2 Router 実装パターン

from typing import Any

TOOL_EXECUTORS: dict[str, Callable] = {
    "list_employees": execute_list_employees,
    "list_attendance": execute_list_attendance,
    "calculate_overtime": execute_calculate_overtime,
}

async def handle_custom_tool(
    tool_name: str,
    tool_input: dict[str, Any],
    tenant_id: str,
    conn: aiosqlite.Connection,
) -> str:
    """Custom Tool を実行し、結果を JSON 文字列で返す。"""
    executor = TOOL_EXECUTORS.get(tool_name)
    if executor is None:
        return json.dumps({"error": f"Unknown tool: {tool_name}"})

    try:
        result = await executor(tool_input, tenant_id, conn)
        await insert_audit_log(conn, tenant_id, "system", None,
            f"tool.{tool_name}", "execution", None,
            {"input": tool_input, "output_summary": _summarize(result)})
        return json.dumps(result, ensure_ascii=False)
    except FreeeAPIError as e:
        return json.dumps({"error": str(e), "retryable": e.retryable})

4. ツールスキーマ定義

4.1 list_employees

{
  "name": "list_employees",
  "description": "従業員一覧を取得する。部署やステータスでフィルタ可能。",
  "parameters": {
    "type": "object",
    "properties": {
      "company_id": {
        "type": "string",
        "description": "事業所 ID(省略時はデフォルト事業所)"
      },
      "department": {
        "type": "string",
        "description": "部署名でフィルタ(省略時は全部署)"
      },
      "status": {
        "type": "string",
        "enum": ["active", "retired", "on_leave"],
        "description": "在籍ステータスでフィルタ(省略時は全件)"
      }
    }
  }
}

出力:

{
  "employees": [
    {
      "id": "string",
      "name": "string",
      "department": "string",
      "position": "string",
      "employment_type": "full_time | part_time | contract",
      "status": "active | retired | on_leave",
      "hire_date": "YYYY-MM-DD"
    }
  ],
  "total_count": 42
}

4.2 list_attendance

{
  "name": "list_attendance",
  "description": "指定従業員の指定月の勤怠データを取得する。",
  "parameters": {
    "type": "object",
    "properties": {
      "company_id": { "type": "string" },
      "employee_id": { "type": "string", "description": "従業員 ID" },
      "month": { "type": "string", "description": "対象月 (YYYY-MM)" }
    },
    "required": ["employee_id", "month"]
  }
}

出力:

{
  "employee_id": "string",
  "month": "YYYY-MM",
  "records": [
    {
      "date": "YYYY-MM-DD",
      "day_type": "weekday | weekend | holiday",
      "clock_in": "HH:MM",
      "clock_out": "HH:MM",
      "break_minutes": 60,
      "work_minutes": 480,
      "overtime_minutes": 0,
      "is_absent": false,
      "is_paid_leave": false
    }
  ],
  "total_records": 20
}

4.3 calculate_overtime

{
  "name": "calculate_overtime",
  "description": "指定従業員の指定月の残業時間を集計する。list_attendance の結果を基に計算する。",
  "parameters": {
    "type": "object",
    "properties": {
      "company_id": { "type": "string" },
      "employee_id": { "type": "string" },
      "month": { "type": "string", "description": "対象月 (YYYY-MM)" }
    },
    "required": ["employee_id", "month"]
  }
}

出力:

{
  "employee_id": "string",
  "month": "YYYY-MM",
  "total_work_minutes": 9600,
  "total_overtime_minutes": 120,
  "working_days": 20,
  "breakdown": {
    "normal_overtime_minutes": 100,
    "late_night_overtime_minutes": 20,
    "holiday_work_minutes": 0
  }
}

4.4 write_rows_to_google_sheet (MCP Server)

{
  "name": "write_rows_to_google_sheet",
  "description": "Google Sheets にデータ行を書き込む。",
  "parameters": {
    "type": "object",
    "properties": {
      "spreadsheet_id": { "type": "string" },
      "range": { "type": "string", "description": "A1 表記 (例: Sheet1!A1)" },
      "rows": {
        "type": "array",
        "items": { "type": "array" },
        "description": "書き込む行データの配列"
      }
    },
    "required": ["spreadsheet_id", "range", "rows"]
  }
}

4.5 send_slack_dm (MCP Server)

{
  "name": "send_slack_dm",
  "description": "Slack でユーザーに DM を送信する。",
  "parameters": {
    "type": "object",
    "properties": {
      "user_id": { "type": "string", "description": "Slack ユーザー ID" },
      "message": { "type": "string", "description": "メッセージ本文" }
    },
    "required": ["user_id", "message"]
  }
}

5. 共通スキーマ (Shared Schema)

5.1 設計方針

freee API のレスポンスをそのまま Agent に返さず、ベンダー非依存の共通型に変換する。将来 KOT 等の別ベンダーを追加する際に、L1-L3 のプロンプトを変更せずに済む。

flowchart LR
    A["freee API Response"] --> B["freee Adapter\n(変換ロジック)"]
    B --> C["Shared Schema\n(Agent に返す)"]
    D["KOT API Response"] -.-> E["KOT Adapter\n(Post-MVP)"]
    E -.-> C

5.2 型定義

from dataclasses import dataclass
from datetime import date


@dataclass(frozen=True)
class Employee:
    """ベンダー共通の従業員型。"""
    id: str
    name: str
    department: str
    position: str
    employment_type: str   # full_time | part_time | contract
    status: str            # active | retired | on_leave
    hire_date: date


@dataclass(frozen=True)
class AttendanceRecord:
    """ベンダー共通の勤怠レコード型。"""
    date: date
    day_type: str           # weekday | weekend | holiday
    clock_in: str | None    # HH:MM
    clock_out: str | None   # HH:MM
    break_minutes: int
    work_minutes: int
    overtime_minutes: int
    is_absent: bool
    is_paid_leave: bool


@dataclass(frozen=True)
class OvertimeSummary:
    """ベンダー共通の残業集計型。"""
    employee_id: str
    month: str              # YYYY-MM
    total_work_minutes: int
    total_overtime_minutes: int
    working_days: int
    normal_overtime_minutes: int
    late_night_overtime_minutes: int
    holiday_work_minutes: int

5.3 freee アダプタ

class FreeeAdapter:
    """freee API レスポンスを共通スキーマに変換する。"""

    @staticmethod
    def to_employee(raw: dict) -> Employee:
        return Employee(
            id=str(raw["id"]),
            name=f"{raw['last_name']} {raw['first_name']}",
            department=raw.get("department", {}).get("name", ""),
            position=raw.get("position", ""),
            employment_type=_map_employment_type(raw.get("employment_type", "")),
            status=_map_status(raw.get("employment_status", "")),
            hire_date=date.fromisoformat(raw.get("hire_date", "2000-01-01")),
        )

    @staticmethod
    def to_attendance_record(raw: dict) -> AttendanceRecord:
        return AttendanceRecord(
            date=date.fromisoformat(raw["date"]),
            day_type=_map_day_type(raw.get("day_pattern", "")),
            clock_in=raw.get("clock_in_at"),
            clock_out=raw.get("clock_out_at"),
            break_minutes=raw.get("break_minutes", 0),
            work_minutes=raw.get("normal_work_minutes", 0),
            overtime_minutes=raw.get("overtime_work_minutes", 0),
            is_absent=raw.get("is_absence", False),
            is_paid_leave=raw.get("is_paid_holiday", False),
        )


def _map_employment_type(freee_type: str) -> str:
    mapping = {"regular": "full_time", "part_time": "part_time",
               "contract": "contract", "temporary": "contract"}
    return mapping.get(freee_type, "full_time")

def _map_status(freee_status: str) -> str:
    mapping = {"employed": "active", "retired": "retired",
               "on_leave": "on_leave"}
    return mapping.get(freee_status, "active")

def _map_day_type(freee_pattern: str) -> str:
    mapping = {"normal_day": "weekday", "prescribed_holiday": "weekend",
               "legal_holiday": "holiday"}
    return mapping.get(freee_pattern, "weekday")

5.4 Post-MVP: KOT アダプタの追加例

class KOTAdapter:
    """KOT API レスポンスを共通スキーマに変換する。"""

    @staticmethod
    def to_employee(raw: dict) -> Employee:
        return Employee(
            id=raw["employeeKey"],
            name=raw["fullName"],
            department=raw.get("divisionName", ""),
            # ... KOT 固有のマッピング
        )

ツール実行時のベンダー切替:

async def execute_list_employees(tool_input: dict, tenant_id: str, conn) -> dict:
    vendor = await get_tenant_vendor(conn, tenant_id)  # "freee" | "kot"

    if vendor == "freee":
        raw = await freee_client.list_employees(tool_input)
        employees = [FreeeAdapter.to_employee(e) for e in raw]
    elif vendor == "kot":
        raw = await kot_client.list_employees(tool_input)
        employees = [KOTAdapter.to_employee(e) for e in raw]

    return {
        "employees": [asdict(e) for e in employees],
        "total_count": len(employees),
    }

6. freee API Client

6.1 エンドポイントマッピング

Custom Tool freee API メソッド パス
list_employees 従業員一覧 GET /hr/api/v1/employees
list_attendance 勤怠データ GET /hr/api/v1/employees/{id}/work_records
calculate_overtime (Backend 集計) - list_attendance 結果を計算

ベース URL は https://api.freee.co.jpbackend/src/tools/freee_client.py 参照)。MOCK_MODE=true のときは freee_adapter.py がモックデータを返すため外部通信は発生しない。

6.2 OAuth Token Manager

class FreeeTokenManager:
    """freee OAuth トークンの管理。自動リフレッシュ対応。"""

    async def get_access_token(self, tenant_id: str, conn) -> str:
        integration = await get_integration(conn, tenant_id, "freee")
        creds = decrypt_credentials(integration.credentials)

        if self._is_expired(creds):
            creds = await self._refresh(creds)
            await update_integration_credentials(conn, integration.id, creds)

        return creds["access_token"]

    async def _refresh(self, creds: dict) -> dict:
        resp = await httpx.post("https://accounts.secure.freee.co.jp/public_api/token", data={
            "grant_type": "refresh_token",
            "refresh_token": creds["refresh_token"],
            "client_id": settings.freee_client_id,
            "client_secret": settings.freee_client_secret,
        })
        resp.raise_for_status()
        return resp.json()

6.3 エラーハンドリング

HTTP ステータス 対処 リトライ
401 トークンリフレッシュ → リトライ 1 回
429 Retry-After ヘッダに従い待機 最大 3 回
5xx Exponential backoff 最大 3 回
4xx (その他) エラーテキストを Agent に返す しない
class FreeeAPIError(Exception):
    def __init__(self, message: str, status_code: int, retryable: bool = False):
        super().__init__(message)
        self.status_code = status_code
        self.retryable = retryable

7. L2/L3 用ツールカタログ

7.1 L2 用ツールカタログ (自然言語)

L2 で Agent に「使えるツール」を伝えるためのテキスト。L2 の user message に含める。

利用可能なツール:
- list_employees: 従業員一覧を取得(部署・ステータスでフィルタ可能)
- list_attendance: 指定従業員の指定月の勤怠データを取得
- calculate_overtime: 指定従業員の指定月の残業時間を計算
- write_rows_to_google_sheet: Google Sheets にデータを書き込み
- send_slack_dm: Slack で DM を送信

7.2 L3 用ツールスキーマ (JSON)

L3 で Agent がステップ計画を作成する際に参照する JSON Schema。Section 4 のスキーマをまとめて渡す。

DEFAULT_TOOL_SCHEMAS = json.dumps([
    # Section 4.1 ~ 4.5 のスキーマを配列で提供
], ensure_ascii=False, indent=2)

8. Post-MVP 拡張

項目 MVP Post-MVP
ベンダー freee のみ KOT, SmartHR 等のアダプタ追加
ツール数 5 給与計算、社会保険、年末調整等
Policy Engine 全ツール自動承認 tool_permissions テーブルで allow/deny/requires_confirmation
動的カタログ ハードコード テナント設定から有効ツールを動的生成
Shared Schema dataclass Pydantic v2 でバリデーション強化
MCP ハイブリッド 2 パターン並行 Custom Tool + MCP の判定を Policy Engine が自動化

変更履歴

日付 バージョン 変更内容
2026-04-18 v1.0 初版作成
2026-04-19 v1.1 Custom Tool Executor が freee 本番パスであることを明確化。custom_tool_use イベント処理フローを追記 (issue #11)
2026-04-20 v1.2 freee API パスを /hr/api/v1/ に修正、イベント名を agent.custom_tool_use に統一(実装整合)