Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ npm run dev:client # Vite dev server → http://localhost:5173
npm run dev:server # tsx watch mode → http://localhost:3000
npm run dev:functions # Cloudflare Pages Functions ローカル実行

# MCP ブリッジ(stdio)
npm run build:mcp # mcp/ をビルド

# Lint / Format
npm run check # Biome でチェックのみ
npm run fix:safe # 安全な自動修正(pre-commit フックで自動実行)
Expand Down Expand Up @@ -79,6 +82,31 @@ import dayjs from "../lib/dayjs";
- デフォルト: 参加形態が0件の場合は `common/colors.ts` の `DEFAULT_PARTICIPATION_OPTION` を使用してデフォルトを自動作成(label: "参加", color: "#0F82B1")。
- 削除制限: Slot が紐づいている参加形態は削除不可(サーバー側で検証)。

### ユースケース層

ドメインロジックは `server/src/usecases/` にあり、Hono のルートも MCP のツールも**この関数だけを呼ぶ**。HTTP を経由して自分の API を叩き直す構成は採らない。

- 実行主体は `Actor`(`browserId` / `via: "web" | "mcp"` / `scopes`)に正規化する。権限判定はビュー層ではなくここに置く。
- 業務エラーは `UseCaseError` を投げ、ルート層で HTTP に変換する。メッセージは LLM がそのまま読んで復旧できるよう、**どう直せばよいかまで自然文で書く**。
- Slot の日程範囲・時間帯・15分グリッド・日跨ぎ・参加形態 ID は `usecases/projects.ts` で検証する。Web UI ではカレンダーの構造上踏まないが、MCP 経由では UI を通らないため必須(範囲外 Slot は描画クラッシュの原因になった実績がある)。

### MCP サーバー

**仕様は [`docs/mcp.md`](./docs/mcp.md) が正本。** ツール・認証・エンドポイント・エラーの一覧はそちらを参照する。

コードを触るときに関係する点だけ挙げる。

- `POST /mcp` として既存の Hono アプリに同居する(`server/src/routes/mcp.ts`)。別サービスに切らない。
- **ツール定義は `server/src/mcp/server.ts` に集約**し、ドメインロジックは `usecases/` を直接呼ぶ。HTTP で自分の API を叩き直さない。
- `mcp/` ワークスペースの stdio ブリッジは JSON-RPC を中継するだけでツールを持たない。**ツールを追加しても `mcp/` は変更不要**。
- `/mcp` は `browserIdMiddleware` を通さない。通すとリクエストごとに孤立した `browserId` が発行されてしまう(`main.ts` の分岐)。
- トランスポートは **stateless** に保つこと。fly.io の `auto_stop_machines` でマシンが停止してもセッションが壊れないようにするため。
- ツールの `description` と `annotations` はそのまま LLM への仕様書になる。`docs/mcp.md` の「共通の約束」と食い違わないようにする。

### 空き時間の集計

`server/src/usecases/availability.ts` の `computeAvailability` が、全 Slot の境界点(`from` / `to`)を掃引して参加者集合が一定な区間に分割する。クライアントの `CalendarMatrix` は描画用なのでサーバー側では流用できない。

### Cloudflare Pages Functions

`client/functions/[[path]].ts` が catch-all ルートとして動作し、`/e/:eventId` パターンの OG メタタグを動的に書き換える。ローカル確認は `npm run dev:functions` を使う(`npm run dev:client` の Vite dev server では Functions は動作しない)。
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

とりあえずみんなの空いている時間を訊いてから、何を何時間やるか決めたい。そんな仲間うちでの日程調整に最適なツールです。

## AI 連携(MCP)

ChatGPT / Claude / Claude Code からイベントの確認や日程の提出ができる。
接続方法とツールの仕様は [`docs/mcp.md`](./docs/mcp.md) を参照。

## 開発

### 要件
Expand Down Expand Up @@ -67,6 +72,14 @@ http://localhost:5173 にアクセスします。



### MCP サーバー

ローカルで動かす場合、`/mcp` は `npm run dev:server` に同居している。
連携コードは http://localhost:5173/settings/mcp から発行できる。
stdio ブリッジをローカルの API に向けるには `ITSUHIMA_API=http://localhost:3000` を指定する。

詳細は [`docs/mcp.md`](./docs/mcp.md) を参照。

### コードスタイル

コードのリント・フォーマット
Expand Down
2 changes: 2 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import HomePage from "./pages/Home.tsx";
import LandingPage from "./pages/Landing.tsx";
import NotFoundPage from "./pages/NotFound.tsx";
import ProjectPage from "./pages/Project.tsx";
import McpSettingsPage from "./pages/settings/Mcp.tsx";

/**
* Nano ID 形式の正規表現。
Expand Down Expand Up @@ -40,6 +41,7 @@ export default function App() {
<Route index element={<LandingPage />} />
<Route path="home" element={<HomePage />} />
<Route path="new" element={<ProjectPage />} />
<Route path="settings/mcp" element={<McpSettingsPage />} />
<Route path="e">
<Route path=":eventId" element={<Outlet />}>
<Route index element={<SubmissionPage />} />
Expand Down
13 changes: 13 additions & 0 deletions client/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export default function Header({ compact = false }: { compact?: boolean }) {
<NavLink to="/home" className="font-medium text-base-content/70 text-sm transition-colors hover:text-primary">
ホーム
</NavLink>
<NavLink
to="/settings/mcp"
className="font-medium text-base-content/70 text-sm transition-colors hover:text-primary"
>
AI 連携
</NavLink>
<a
href={EXTERNAL_LINKS.GUIDE}
target="_blank"
Expand Down Expand Up @@ -66,6 +72,13 @@ export default function Header({ compact = false }: { compact?: boolean }) {
>
ホーム
</NavLink>
<NavLink
to="/settings/mcp"
className="block rounded-lg px-3 py-2 font-medium text-base text-base-content/70 hover:bg-base-200 hover:text-primary"
onClick={() => setIsMenuOpen(false)}
>
AI 連携
</NavLink>
<a
href={EXTERNAL_LINKS.GUIDE}
target="_blank"
Expand Down
1 change: 1 addition & 0 deletions client/src/constants/links.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const EXTERNAL_LINKS = {
GUIDE: "https://utcode.notion.site/1e4ca5f557bc80f2b697ca7b9342dc89?pvs=4",
FEEDBACK: "https://forms.gle/AB6xbgKjnDv5m1nm6",
MCP_DOC: "https://github.com/ut-code/itsuhima/blob/main/docs/mcp.md",
} as const;
24 changes: 3 additions & 21 deletions client/src/pages/Project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { EXTERNAL_LINKS } from "../constants/links";
import dayjs from "../lib/dayjs";
import { projectReviver } from "../revivers";
import type { Project } from "../types";
import { API_ENDPOINT, FRONTEND_ORIGIN } from "../utils";
import { API_ENDPOINT, extractErrorMessage, FRONTEND_ORIGIN } from "../utils";

const client = hc<AppType>(API_ENDPOINT);

Expand Down Expand Up @@ -59,15 +59,7 @@ export default function ProjectPage() {
const parsedData = projectReviver(data);
setProject(parsedData);
} else {
let errorMessage = "プロジェクトの取得に失敗しました。";
try {
const data = await res.json();
if (data && typeof data.message === "string" && data.message.trim()) {
errorMessage = data.message.trim();
}
} catch (_) {
// レスポンスがJSONでない場合は無視
}
const errorMessage = await extractErrorMessage(res, "プロジェクトの取得に失敗しました。");
setToast({
message: errorMessage,
variant: "error",
Expand Down Expand Up @@ -254,17 +246,7 @@ export default function ProjectPage() {
});
setTimeout(() => setToast(null), 3000);
} else {
let errorMessage = "更新に失敗しました。";
try {
const data = await res.json();
if (data && typeof data.message === "string" && data.message.trim()) {
errorMessage = data.message.trim();
} else if (res.status === 403) {
errorMessage = "権限がありません。";
}
} catch (_) {
if (res.status === 403) errorMessage = "権限がありません。";
}
const errorMessage = await extractErrorMessage(res, "更新に失敗しました。");
setToast({
message: errorMessage,
variant: "error",
Expand Down
12 changes: 2 additions & 10 deletions client/src/pages/eventId/Submission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { Calendar } from "../../components/Calendar";
import Header from "../../components/Header";
import { projectReviver } from "../../revivers";
import type { Project, Slot } from "../../types";
import { API_ENDPOINT } from "../../utils";
import { API_ENDPOINT, extractErrorMessage } from "../../utils";

const client = hc<AppType>(API_ENDPOINT);

Expand Down Expand Up @@ -102,15 +102,7 @@ export default function SubmissionPage() {
const parsedData = projectReviver(data);
setProject(parsedData);
} else {
let errorMessage = "プロジェクトの取得に失敗しました。";
try {
const data = await res.json();
if (data && typeof data.message === "string" && data.message.trim()) {
errorMessage = data.message.trim();
}
} catch (_) {
// レスポンスがJSONでない場合は無視
}
const errorMessage = await extractErrorMessage(res, "プロジェクトの取得に失敗しました。");
setToast({
message: errorMessage,
variant: "error",
Expand Down
Loading
Loading