Slack MCP Agent — Custom Tool 設計¶
v1.2 / 2026-04-20
ツールカタログ、共通スキーマ、ベンダー抽象化の設計を定義する。
Custom Tool Executor は freee HR 3 ツールの本番実行パスである(docs/architecture.mdSection 4.4, 8.2 参照)。
MCP Server hr-freee は開発テスト用。出力系ツール (Google Sheets, Slack DM) は MCP Server common で実行する。
docs/tech-decisions.mdADR-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 | スプレッドシート書込 | |
| 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.jp(backend/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 に統一(実装整合) |