) => ,
+ Select: ({
+ children,
+ onValueChange,
+ value,
+ disabled,
+ }: {
+ children: React.ReactNode;
+ onValueChange?: (v: string) => void;
+ value?: string;
+ disabled?: boolean;
+ }) => {
+ if (onValueChange && value) {
+ selectCallbacks.set(value, onValueChange);
+ }
+ return (
+
+ {children}
+
+ );
+ },
+ SelectContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => (
+
+ ),
+ SelectTrigger: ({ children, ...props }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ SelectValue: () => null,
+ Table: ({ children }: { children: React.ReactNode }) => ,
+ TableBody: ({ children }: { children: React.ReactNode }) => {children},
+ TableCell: ({ children }: { children: React.ReactNode }) => {children} | ,
+ TableHead: ({ children }: { children: React.ReactNode }) => {children} | ,
+ TableHeader: ({ children }: { children: React.ReactNode }) => {children},
+ TableRow: ({ children }: { children: React.ReactNode }) => {children}
,
+ };
+});
+
+vi.mock("@zedi/ui/lib/utils", () => ({
+ cn: (...args: unknown[]) => args.filter(Boolean).join(" "),
}));
-vi.mock("./UsersContent", () => ({
- UsersContent: ({
- users,
- total,
- page,
- pageSize,
- onPageChange,
- loading,
- }: {
- users: unknown[];
- total: number;
- page: number;
- pageSize: number;
- onPageChange: (p: number) => void;
- loading: boolean;
- }) => (
-
- {users.length}
- {total}
- {page}
- {pageSize}
- {total > pageSize && (
- <>
-
-
- >
- )}
-
- ),
+vi.mock("./UserCard", () => ({
+ UserCard: () => UserCard
,
}));
-const mockUsers = (n: number, offset: number) =>
+const mockUsers = (n: number, offset: number): UserAdmin[] =>
Array.from({ length: n }, (_, i) => ({
id: `user-${offset + i}`,
email: `user${offset + i}@example.com`,
@@ -91,16 +148,35 @@ const mockUsers = (n: number, offset: number) =>
pageCount: 0,
}));
+const singleActiveUser: UserAdmin = {
+ id: "user-1",
+ email: "user@example.com",
+ name: "Test User",
+ role: "user",
+ status: "active",
+ suspendedAt: null,
+ suspendedReason: null,
+ suspendedBy: null,
+ createdAt: "2026-01-01T00:00:00Z",
+ pageCount: 0,
+};
+
+function mockListResponse(users: UserAdmin[] = [singleActiveUser], total = users.length) {
+ return vi.spyOn(adminApi, "getUsers").mockResolvedValue({ users, total });
+}
+
describe("Users (admin)", () => {
beforeEach(() => {
vi.clearAllMocks();
+ selectCallbacks.clear();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
});
it("calls getUsers with offset 0 on mount", async () => {
- const getUsers = vi.spyOn(adminApi, "getUsers").mockResolvedValue({
- users: mockUsers(10, 0),
- total: 100,
- });
+ const getUsers = mockListResponse(mockUsers(10, 0), 100);
render();
@@ -140,4 +216,250 @@ describe("Users (admin)", () => {
});
expect(getUsers).toHaveBeenCalledTimes(2);
});
+
+ it("debounces search input before calling getUsers with search param", async () => {
+ vi.useFakeTimers();
+ const getUsers = mockListResponse();
+
+ render();
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(getUsers).toHaveBeenCalledTimes(1);
+
+ fireEvent.change(screen.getByLabelText("メールで検索"), { target: { value: "alice" } });
+ expect(getUsers).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300);
+ await Promise.resolve();
+ });
+
+ expect(getUsers).toHaveBeenCalledWith(
+ expect.objectContaining({
+ search: "alice",
+ offset: 0,
+ }),
+ );
+ });
+
+ it("calls getUsers with status filter when status filter changes", async () => {
+ const getUsers = mockListResponse();
+
+ render();
+ await waitFor(() => expect(getUsers).toHaveBeenCalledTimes(1));
+
+ const statusCallback = selectCallbacks.get("all");
+ expect(statusCallback).toBeDefined();
+ React.act(() => {
+ statusCallback?.("suspended");
+ });
+
+ await waitFor(() => {
+ expect(getUsers).toHaveBeenCalledWith(
+ expect.objectContaining({
+ status: "suspended",
+ offset: 0,
+ }),
+ );
+ });
+ });
+
+ it("displays load error when getUsers fails", async () => {
+ vi.spyOn(adminApi, "getUsers").mockRejectedValueOnce(new Error("load failed"));
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("load failed")).toBeInTheDocument();
+ });
+ });
+
+ it("does not call patchUserRole when role is unchanged", async () => {
+ const getUsers = mockListResponse();
+ const patchUserRole = vi.spyOn(adminApi, "patchUserRole");
+
+ render();
+ await waitFor(() => expect(getUsers).toHaveBeenCalled());
+
+ const roleCallback = selectCallbacks.get("user");
+ React.act(() => {
+ roleCallback?.("user");
+ });
+
+ expect(patchUserRole).not.toHaveBeenCalled();
+ expect(screen.queryByRole("heading", { name: "ロールを変更" })).not.toBeInTheDocument();
+ });
+
+ it("calls patchUserRole and reloads on successful role change", async () => {
+ const getUsers = mockListResponse();
+ const patchUserRole = vi.spyOn(adminApi, "patchUserRole").mockResolvedValue({
+ user: { ...singleActiveUser, role: "admin" },
+ });
+
+ render();
+ await waitFor(() => expect(getUsers).toHaveBeenCalledTimes(1));
+
+ React.act(() => {
+ selectCallbacks.get("user")?.("admin");
+ });
+ await userEvent.click(screen.getByRole("button", { name: "変更する" }));
+
+ await waitFor(() => {
+ expect(patchUserRole).toHaveBeenCalledWith("user-1", "admin");
+ });
+ await waitFor(() => expect(getUsers).toHaveBeenCalledTimes(2));
+ });
+
+ it("shows error when patchUserRole fails", async () => {
+ mockListResponse();
+ vi.spyOn(adminApi, "patchUserRole").mockRejectedValueOnce(new Error("role patch failed"));
+
+ render();
+ await waitFor(() => expect(screen.getByText("user@example.com")).toBeInTheDocument());
+
+ React.act(() => {
+ selectCallbacks.get("user")?.("admin");
+ });
+ await userEvent.click(screen.getByRole("button", { name: "変更する" }));
+
+ await waitFor(() => {
+ expect(screen.getByText("role patch failed")).toBeInTheDocument();
+ });
+ });
+
+ it("calls suspendUser and reloads on successful suspend", async () => {
+ const getUsers = mockListResponse();
+ const suspendUser = vi.spyOn(adminApi, "suspendUser").mockResolvedValue({
+ user: { ...singleActiveUser, status: "suspended" },
+ });
+
+ render();
+ await waitFor(() => expect(getUsers).toHaveBeenCalledTimes(1));
+
+ const suspendButtons = screen.getAllByRole("button", { name: "サスペンド" });
+ expect(suspendButtons.length).toBeGreaterThan(0);
+ await userEvent.click(suspendButtons[0] as HTMLElement);
+ await userEvent.click(
+ within(screen.getByTestId("alert-dialog")).getByRole("button", { name: "サスペンド" }),
+ );
+
+ await waitFor(() => {
+ expect(suspendUser).toHaveBeenCalledWith("user-1", undefined);
+ });
+ await waitFor(() => expect(getUsers).toHaveBeenCalledTimes(2));
+ });
+
+ it("shows error when suspendUser fails", async () => {
+ mockListResponse();
+ vi.spyOn(adminApi, "suspendUser").mockRejectedValueOnce(new Error("suspend failed"));
+
+ render();
+ await waitFor(() => expect(screen.getByText("user@example.com")).toBeInTheDocument());
+
+ const suspendButtons = screen.getAllByRole("button", { name: "サスペンド" });
+ expect(suspendButtons.length).toBeGreaterThan(0);
+ await userEvent.click(suspendButtons[0] as HTMLElement);
+ await userEvent.click(
+ within(screen.getByTestId("alert-dialog")).getByRole("button", { name: "サスペンド" }),
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("suspend failed")).toBeInTheDocument();
+ });
+ });
+
+ it("calls unsuspendUser and reloads on successful unsuspend", async () => {
+ const suspendedUser: UserAdmin = {
+ ...singleActiveUser,
+ status: "suspended",
+ suspendedAt: "2026-01-01T00:00:00Z",
+ suspendedReason: "test",
+ suspendedBy: "admin-1",
+ };
+ const getUsers = mockListResponse([suspendedUser]);
+ const unsuspendUser = vi.spyOn(adminApi, "unsuspendUser").mockResolvedValue({
+ user: { ...suspendedUser, status: "active" },
+ });
+
+ render();
+ await waitFor(() => expect(getUsers).toHaveBeenCalledTimes(1));
+
+ await userEvent.click(screen.getByRole("button", { name: "復活" }));
+ await userEvent.click(screen.getByRole("button", { name: "復活させる" }));
+
+ await waitFor(() => {
+ expect(unsuspendUser).toHaveBeenCalledWith("user-1");
+ });
+ await waitFor(() => expect(getUsers).toHaveBeenCalledTimes(2));
+ });
+
+ it("shows error when unsuspendUser fails", async () => {
+ const suspendedUser: UserAdmin = {
+ ...singleActiveUser,
+ status: "suspended",
+ suspendedAt: "2026-01-01T00:00:00Z",
+ suspendedReason: "test",
+ suspendedBy: "admin-1",
+ };
+ mockListResponse([suspendedUser]);
+ vi.spyOn(adminApi, "unsuspendUser").mockRejectedValueOnce(new Error("unsuspend failed"));
+
+ render();
+ await waitFor(() => expect(screen.getByRole("button", { name: "復活" })).toBeInTheDocument());
+
+ await userEvent.click(screen.getByRole("button", { name: "復活" }));
+ await userEvent.click(screen.getByRole("button", { name: "復活させる" }));
+
+ await waitFor(() => {
+ expect(screen.getByText("unsuspend failed")).toBeInTheDocument();
+ });
+ });
+
+ it("calls deleteUser and reloads on successful delete", async () => {
+ const getUsers = mockListResponse();
+ vi.spyOn(adminApi, "getUserImpact").mockResolvedValue({
+ notesCount: 0,
+ sessionsCount: 0,
+ activeSubscription: false,
+ lastAiUsageAt: null,
+ });
+ const deleteUser = vi.spyOn(adminApi, "deleteUser").mockResolvedValue({
+ user: { ...singleActiveUser, status: "deleted" },
+ });
+
+ render();
+ await waitFor(() => expect(getUsers).toHaveBeenCalledTimes(1));
+
+ await userEvent.click(screen.getByRole("button", { name: "削除" }));
+ await userEvent.type(screen.getByPlaceholderText("user@example.com"), "user@example.com");
+ await userEvent.click(screen.getByRole("button", { name: "削除する" }));
+
+ await waitFor(() => {
+ expect(deleteUser).toHaveBeenCalledWith("user-1");
+ });
+ await waitFor(() => expect(getUsers).toHaveBeenCalledTimes(2));
+ });
+
+ it("shows error when deleteUser fails", async () => {
+ mockListResponse();
+ vi.spyOn(adminApi, "getUserImpact").mockResolvedValue({
+ notesCount: 0,
+ sessionsCount: 0,
+ activeSubscription: false,
+ lastAiUsageAt: null,
+ });
+ vi.spyOn(adminApi, "deleteUser").mockRejectedValueOnce(new Error("delete failed"));
+
+ render();
+ await waitFor(() => expect(screen.getByRole("button", { name: "削除" })).toBeInTheDocument());
+
+ await userEvent.click(screen.getByRole("button", { name: "削除" }));
+ await userEvent.type(screen.getByPlaceholderText("user@example.com"), "user@example.com");
+ await userEvent.click(screen.getByRole("button", { name: "削除する" }));
+
+ await waitFor(() => {
+ expect(screen.getByText("delete failed")).toBeInTheDocument();
+ });
+ });
});
diff --git a/server/mcp/bun.lock b/server/mcp/bun.lock
index feb27a70..fe008ebd 100644
--- a/server/mcp/bun.lock
+++ b/server/mcp/bun.lock
@@ -13,6 +13,7 @@
},
"devDependencies": {
"@types/node": "^25.3.3",
+ "@vitest/coverage-v8": "4.1.4",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"vitest": "^4.0.18",
@@ -20,6 +21,16 @@
},
},
"packages": {
+ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
+
+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
+
+ "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
+
+ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
+
+ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
+
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
@@ -80,8 +91,12 @@
"@hono/node-server": ["@hono/node-server@1.19.13", "", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
@@ -132,6 +147,8 @@
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
+ "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.4", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.4", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.4", "vitest": "4.1.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w=="],
+
"@vitest/expect": ["@vitest/expect@4.1.4", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.4", "@vitest/utils": "4.1.4", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww=="],
"@vitest/mocker": ["@vitest/mocker@4.1.4", "", { "dependencies": { "@vitest/spy": "4.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg=="],
@@ -154,6 +171,8 @@
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
+ "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA=="],
+
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -248,12 +267,16 @@
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
+ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
+
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="],
+ "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
+
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
@@ -274,8 +297,16 @@
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+ "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
+
+ "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
+
+ "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
+
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
+ "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
+
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
@@ -306,6 +337,10 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+ "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
+
+ "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
+
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
@@ -370,6 +405,8 @@
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+ "semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
+
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
@@ -398,6 +435,8 @@
"std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="],
+ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
diff --git a/server/mcp/package.json b/server/mcp/package.json
index 974e44f6..c2e2c545 100644
--- a/server/mcp/package.json
+++ b/server/mcp/package.json
@@ -28,6 +28,7 @@
},
"devDependencies": {
"@types/node": "^25.3.3",
+ "@vitest/coverage-v8": "4.1.4",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"vitest": "^4.0.18"
diff --git a/server/mcp/src/__tests__/http.test.ts b/server/mcp/src/__tests__/http.test.ts
index a4dc3052..7cbdb7a2 100644
--- a/server/mcp/src/__tests__/http.test.ts
+++ b/server/mcp/src/__tests__/http.test.ts
@@ -2,43 +2,129 @@
* http.ts のテスト
*
* - /health は 200 + ok
- * - /mcp に Authorization なしで POST すると 401
+ * - /mcp の Bearer 認証と MCP プロキシ処理
*
- * Tests for the HTTP transport entry point — health and unauthorized handling.
- * Full MCP-over-HTTP smoke test is intentionally deferred to manual verification
- * to keep the test suite hermetic.
+ * Tests for the HTTP transport entry point — health, auth, and per-request MCP wiring.
*/
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const mockConnect = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
+const mockClose = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
+const mockHandleRequest = vi.hoisted(() => vi.fn());
+const mockCreateMcpServer = vi.hoisted(() => vi.fn());
+const mockHttpZediClient = vi.hoisted(() => vi.fn());
+
+vi.mock("../server.js", () => ({
+ createMcpServer: mockCreateMcpServer,
+}));
+
+vi.mock("@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js", () => ({
+ WebStandardStreamableHTTPServerTransport: vi.fn(
+ function MockWebStandardStreamableHTTPServerTransport() {
+ return { handleRequest: mockHandleRequest };
+ },
+ ),
+}));
+
+vi.mock("../client/httpClient.js", () => ({
+ HttpZediClient: mockHttpZediClient,
+}));
+
import { createHttpApp } from "../http.js";
+import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
+
+const API_URL = "https://api.example.com";
+const MCP_BODY = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" });
+
+function mcpPost(headers: Record = {}) {
+ const app = createHttpApp(API_URL);
+ return app.request("/mcp", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...headers },
+ body: MCP_BODY,
+ });
+}
describe("createHttpApp", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockCreateMcpServer.mockReturnValue({
+ connect: mockConnect,
+ close: mockClose,
+ });
+ mockHandleRequest.mockResolvedValue(
+ new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: {} }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ mockHttpZediClient.mockImplementation(function MockHttpZediClient(opts: {
+ baseUrl: string;
+ token: string;
+ }) {
+ return { baseUrl: opts.baseUrl, token: opts.token };
+ });
+ });
+
it("GET /health returns ok", async () => {
- const app = createHttpApp("https://api.example.com");
+ const app = createHttpApp(API_URL);
const res = await app.request("/health");
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean; server: string; apiUrl: string };
expect(body.ok).toBe(true);
expect(body.server).toBe("zedi-mcp");
- expect(body.apiUrl).toBe("https://api.example.com");
+ expect(body.apiUrl).toBe(API_URL);
});
it("POST /mcp without Authorization returns 401", async () => {
- const app = createHttpApp("https://api.example.com");
- const res = await app.request("/mcp", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
- });
+ const res = await mcpPost();
expect(res.status).toBe(401);
});
it("POST /mcp with malformed Bearer returns 401", async () => {
- const app = createHttpApp("https://api.example.com");
- const res = await app.request("/mcp", {
- method: "POST",
- headers: { "Content-Type": "application/json", Authorization: "Basic xyz" },
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
- });
+ const res = await mcpPost({ Authorization: "Basic xyz" });
expect(res.status).toBe(401);
});
+
+ it("POST /mcp with Bearer empty string returns 401", async () => {
+ const res = await mcpPost({ Authorization: "Bearer " });
+ expect(res.status).toBe(401);
+ expect(mockCreateMcpServer).not.toHaveBeenCalled();
+ });
+
+ it("POST /mcp with valid Bearer token returns 200 (proxied response)", async () => {
+ const res = await mcpPost({ Authorization: "Bearer valid-token" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { jsonrpc: string; id: number; result: unknown };
+ expect(body.jsonrpc).toBe("2.0");
+ expect(body.id).toBe(1);
+ expect(body.result).toEqual({});
+ });
+
+ it("createMcpServer called with HttpZediClient when token present", async () => {
+ await mcpPost({ Authorization: "Bearer valid-token" });
+
+ expect(mockHttpZediClient).toHaveBeenCalledWith({
+ baseUrl: API_URL,
+ token: "valid-token",
+ });
+ const clientInstance = mockHttpZediClient.mock.results[0]?.value;
+ expect(mockCreateMcpServer).toHaveBeenCalledWith(clientInstance);
+ expect(WebStandardStreamableHTTPServerTransport).toHaveBeenCalledWith({
+ sessionIdGenerator: undefined,
+ });
+ expect(mockConnect).toHaveBeenCalledOnce();
+ });
+
+ it("server.close called after request (finally block)", async () => {
+ await mcpPost({ Authorization: "Bearer valid-token" });
+ expect(mockClose).toHaveBeenCalledOnce();
+ });
+
+ it("transport handleRequest error propagates", async () => {
+ mockHandleRequest.mockRejectedValue(new Error("transport failed"));
+ const res = await mcpPost({ Authorization: "Bearer valid-token" });
+ expect(res.status).toBe(500);
+ expect(mockClose).toHaveBeenCalledOnce();
+ });
});
diff --git a/server/mcp/src/__tests__/tools/helpers.test.ts b/server/mcp/src/__tests__/tools/helpers.test.ts
index cd1f1ea8..e141a7dd 100644
--- a/server/mcp/src/__tests__/tools/helpers.test.ts
+++ b/server/mcp/src/__tests__/tools/helpers.test.ts
@@ -8,7 +8,7 @@
* Tests for the MCP tool helpers, including #562 rate-limit rendering.
*/
import { describe, it, expect } from "vitest";
-import { wrapToolHandler } from "../../tools/helpers.js";
+import { jsonResult, textResult, wrapToolHandler } from "../../tools/helpers.js";
import { ZediApiError } from "../../client/errors.js";
describe("wrapToolHandler", () => {
@@ -59,6 +59,15 @@ describe("wrapToolHandler", () => {
expect(result.content[0]?.text).toMatch(/boom/);
});
+ it("jsonResult serializes values as pretty-printed JSON", () => {
+ const result = jsonResult([1, 2]);
+ expect(result.content[0]?.text).toBe(JSON.stringify([1, 2], null, 2));
+ });
+
+ it("textResult returns a single text content item", () => {
+ expect(textResult("ping")).toEqual({ content: [{ type: "text", text: "ping" }] });
+ });
+
it("passes through successful results untouched", async () => {
const result = await wrapToolHandler(
async (input: { x: number }) => ({
diff --git a/server/mcp/src/__tests__/tools/index.test.ts b/server/mcp/src/__tests__/tools/index.test.ts
index 42b0a75c..5a063553 100644
--- a/server/mcp/src/__tests__/tools/index.test.ts
+++ b/server/mcp/src/__tests__/tools/index.test.ts
@@ -2,20 +2,48 @@
* tools/index.ts のユニットテスト
*
* `server.test.ts` は MCP クライアント経由で end-to-end の挙動を見ているのに対し、
- * このテストは `registerAllTools` と `ALL_TOOL_NAMES` のメタ情報の整合性を直接検証する。
+ * このテストは `registerAllTools` と各ツールハンドラを直接検証する。
*
- * - `ALL_TOOL_NAMES` は重複なく zedi_ 接頭辞のみで構成されること
- * - `registerAllTools(server, client)` は `ALL_TOOL_NAMES` の各要素を 1 度ずつ登録すること
- * - 既存ツール定義の一覧と完全に一致すること(追加・削除のたぶん漏れを検知する)
+ * - `ALL_TOOL_NAMES` のメタ情報整合性
+ * - 各ツールハンドラが `ZediClient` を正しい引数で呼び出すこと
+ * - 成功時は JSON content、API 失敗時は `isError` になること
*
- * Unit tests for the registry contract: `registerAllTools` registers exactly the tools
- * advertised in `ALL_TOOL_NAMES`. Catches silent additions or removals.
+ * Unit tests for the registry contract and per-tool handler wiring.
*/
import { describe, expect, it, vi } from "vitest";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { createMcpServer } from "../../server.js";
import type { ZediClient } from "../../client/ZediClient.js";
+import { ZediApiError } from "../../client/errors.js";
+import type { ToolResult } from "../../tools/helpers.js";
import { ALL_TOOL_NAMES, registerAllTools } from "../../tools/index.js";
+const sampleNoteRow = {
+ id: "n1",
+ title: "Note",
+ visibility: "private" as const,
+ edit_permission: "owner_only" as const,
+ is_official: false,
+ owner_id: "user-1",
+ view_count: 0,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+};
+
+/** Tools already exercised end-to-end in `server.test.ts`. / server.test.ts で e2e 済みのツール */
+const TOOLS_COVERED_IN_SERVER_TEST = new Set([
+ "zedi_get_current_user",
+ "zedi_list_pages",
+ "zedi_get_page",
+ "zedi_create_page",
+ "zedi_search",
+ "zedi_clip_url",
+]);
+
+type ToolHandler = (args: Record) => Promise;
+
/**
* Build a fully-mocked ZediClient. Tools call only the methods we register, so types are safe.
* 全メソッドをモック化した ZediClient。
@@ -45,24 +73,334 @@ function createMockClient(): ZediClient {
};
}
-/** Captures registerTool calls so we can assert which tools were registered. / 登録呼び出しを記録するスタブ */
+/** Captures registerTool calls including handler functions. / 登録呼び出しとハンドラを記録するスタブ */
function createServerStub(): {
server: Pick;
registered: string[];
+ handlers: Map;
} {
const registered: string[] = [];
+ const handlers = new Map();
const server = {
- registerTool: ((name: string) => {
+ registerTool: ((name: string, _config: unknown, handler: ToolHandler) => {
registered.push(name);
- // Real `registerTool` returns a `RegisteredTool`, but tools/index.ts does not
- // consume the return value, so a no-op stub is sufficient.
- // 実装の戻り値は `RegisteredTool` だが、tools/index.ts は無視するため空 object を返す。
+ handlers.set(name, handler);
return {} as unknown;
}) as unknown as McpServer["registerTool"],
};
- return { server: server as Pick, registered };
+ return { server: server as Pick, registered, handlers };
+}
+
+function expectJsonContent(result: ToolResult): unknown {
+ expect(result.isError).toBeFalsy();
+ expect(result.content[0]?.type).toBe("text");
+ return JSON.parse(result.content[0]?.text ?? "");
}
+interface ToolHandlerCase {
+ name: (typeof ALL_TOOL_NAMES)[number];
+ args: Record;
+ setup: (client: ZediClient) => void;
+ assertClient: (client: ZediClient) => void;
+ expectedPayload?: unknown;
+}
+
+const TOOL_HANDLER_CASES: ToolHandlerCase[] = [
+ {
+ name: "zedi_get_current_user",
+ args: {},
+ setup: (client) => {
+ vi.mocked(client.getCurrentUser).mockResolvedValue({
+ id: "user-1",
+ email: "a@b.c",
+ name: "Alice",
+ image: null,
+ });
+ },
+ assertClient: (client) => {
+ expect(client.getCurrentUser).toHaveBeenCalledOnce();
+ },
+ expectedPayload: { id: "user-1", email: "a@b.c", name: "Alice", image: null },
+ },
+ {
+ name: "zedi_list_pages",
+ args: { limit: 5, offset: 0, scope: "shared" },
+ setup: (client) => {
+ vi.mocked(client.listPages).mockResolvedValue([
+ {
+ id: "p1",
+ title: "Page",
+ content_preview: null,
+ updated_at: "2026-01-01T00:00:00Z",
+ note_id: null,
+ },
+ ]);
+ },
+ assertClient: (client) => {
+ expect(client.listPages).toHaveBeenCalledWith({ limit: 5, offset: 0, scope: "shared" });
+ },
+ },
+ {
+ name: "zedi_get_page",
+ args: { page_id: "p1" },
+ setup: (client) => {
+ vi.mocked(client.getPageContent).mockResolvedValue({
+ id: "p1",
+ title: "Hello",
+ content_text: "body",
+ content_preview: "Hello",
+ version: 1,
+ updated_at: "2026-01-01T00:00:00Z",
+ });
+ },
+ assertClient: (client) => {
+ expect(client.getPageContent).toHaveBeenCalledWith("p1");
+ },
+ },
+ {
+ name: "zedi_create_page",
+ args: { title: "New page" },
+ setup: (client) => {
+ vi.mocked(client.createPage).mockResolvedValue({
+ id: "page-1",
+ owner_id: "user-1",
+ title: "New page",
+ content_preview: null,
+ thumbnail_url: null,
+ source_url: null,
+ source_page_id: null,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ is_deleted: false,
+ });
+ },
+ assertClient: (client) => {
+ expect(client.createPage).toHaveBeenCalledWith({ title: "New page" });
+ },
+ },
+ {
+ name: "zedi_delete_page",
+ args: { page_id: "p1" },
+ setup: (client) => {
+ vi.mocked(client.deletePage).mockResolvedValue({ id: "p1", deleted: true });
+ },
+ assertClient: (client) => {
+ expect(client.deletePage).toHaveBeenCalledWith("p1");
+ },
+ expectedPayload: { id: "p1", deleted: true },
+ },
+ {
+ name: "zedi_list_notes",
+ args: {},
+ setup: (client) => {
+ vi.mocked(client.listNotes).mockResolvedValue([
+ { ...sampleNoteRow, role: "owner", page_count: 1, member_count: 1 },
+ ]);
+ },
+ assertClient: (client) => {
+ expect(client.listNotes).toHaveBeenCalledOnce();
+ },
+ },
+ {
+ name: "zedi_get_note",
+ args: { note_id: "n1" },
+ setup: (client) => {
+ vi.mocked(client.getNote).mockResolvedValue({
+ id: "n1",
+ title: "Note",
+ visibility: "private",
+ edit_permission: "owner_only",
+ is_official: false,
+ owner_id: "user-1",
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ role: "owner",
+ pages: [],
+ });
+ },
+ assertClient: (client) => {
+ expect(client.getNote).toHaveBeenCalledWith("n1");
+ },
+ },
+ {
+ name: "zedi_create_note",
+ args: { title: "Draft" },
+ setup: (client) => {
+ vi.mocked(client.createNote).mockResolvedValue({ ...sampleNoteRow, title: "Draft" });
+ },
+ assertClient: (client) => {
+ expect(client.createNote).toHaveBeenCalledWith({ title: "Draft" });
+ },
+ },
+ {
+ name: "zedi_update_note",
+ args: { note_id: "n1", title: "Renamed" },
+ setup: (client) => {
+ vi.mocked(client.updateNote).mockResolvedValue({
+ ...sampleNoteRow,
+ title: "Renamed",
+ updated_at: "2026-01-02T00:00:00Z",
+ });
+ },
+ assertClient: (client) => {
+ expect(client.updateNote).toHaveBeenCalledWith("n1", { title: "Renamed" });
+ },
+ },
+ {
+ name: "zedi_delete_note",
+ args: { note_id: "n1" },
+ setup: (client) => {
+ vi.mocked(client.deleteNote).mockResolvedValue({ deleted: true });
+ },
+ assertClient: (client) => {
+ expect(client.deleteNote).toHaveBeenCalledWith("n1");
+ },
+ expectedPayload: { deleted: true },
+ },
+ {
+ name: "zedi_list_note_pages",
+ args: { note_id: "n1" },
+ setup: (client) => {
+ vi.mocked(client.listNotePages).mockResolvedValue([
+ {
+ id: "p1",
+ title: "In note",
+ content_preview: null,
+ updated_at: "2026-01-01T00:00:00Z",
+ note_id: "n1",
+ },
+ ]);
+ },
+ assertClient: (client) => {
+ expect(client.listNotePages).toHaveBeenCalledWith("n1");
+ },
+ },
+ {
+ name: "zedi_add_page_to_note",
+ args: { note_id: "n1", title: "Attached" },
+ setup: (client) => {
+ vi.mocked(client.addPageToNote).mockResolvedValue({
+ id: "p1",
+ title: "Attached",
+ note_id: "n1",
+ });
+ },
+ assertClient: (client) => {
+ expect(client.addPageToNote).toHaveBeenCalledWith("n1", { title: "Attached" });
+ },
+ },
+ {
+ name: "zedi_remove_page_from_note",
+ args: { note_id: "n1", page_id: "p1" },
+ setup: (client) => {
+ vi.mocked(client.removePageFromNote).mockResolvedValue({ removed: true });
+ },
+ assertClient: (client) => {
+ expect(client.removePageFromNote).toHaveBeenCalledWith("n1", "p1");
+ },
+ expectedPayload: { removed: true },
+ },
+ {
+ name: "zedi_reorder_note_pages",
+ args: { note_id: "n1", page_ids: ["p2", "p1"] },
+ setup: (client) => {
+ vi.mocked(client.reorderNotePages).mockResolvedValue({ reordered: true });
+ },
+ assertClient: (client) => {
+ expect(client.reorderNotePages).toHaveBeenCalledWith("n1", ["p2", "p1"]);
+ },
+ expectedPayload: { reordered: true },
+ },
+ {
+ name: "zedi_list_note_members",
+ args: { note_id: "n1" },
+ setup: (client) => {
+ vi.mocked(client.listNoteMembers).mockResolvedValue([
+ { email: "a@b.c", role: "viewer", accepted: true },
+ ]);
+ },
+ assertClient: (client) => {
+ expect(client.listNoteMembers).toHaveBeenCalledWith("n1");
+ },
+ },
+ {
+ name: "zedi_add_note_member",
+ args: { note_id: "n1", email: "guest@example.com", role: "viewer" },
+ setup: (client) => {
+ vi.mocked(client.addNoteMember).mockResolvedValue({
+ email: "guest@example.com",
+ role: "viewer",
+ accepted: false,
+ });
+ },
+ assertClient: (client) => {
+ expect(client.addNoteMember).toHaveBeenCalledWith("n1", {
+ email: "guest@example.com",
+ role: "viewer",
+ });
+ },
+ },
+ {
+ name: "zedi_update_note_member",
+ args: { note_id: "n1", email: "guest@example.com", role: "editor" },
+ setup: (client) => {
+ vi.mocked(client.updateNoteMember).mockResolvedValue({
+ email: "guest@example.com",
+ role: "editor",
+ accepted: true,
+ });
+ },
+ assertClient: (client) => {
+ expect(client.updateNoteMember).toHaveBeenCalledWith("n1", "guest@example.com", "editor");
+ },
+ },
+ {
+ name: "zedi_remove_note_member",
+ args: { note_id: "n1", email: "guest@example.com" },
+ setup: (client) => {
+ vi.mocked(client.removeNoteMember).mockResolvedValue({ removed: true });
+ },
+ assertClient: (client) => {
+ expect(client.removeNoteMember).toHaveBeenCalledWith("n1", "guest@example.com");
+ },
+ expectedPayload: { removed: true },
+ },
+ {
+ name: "zedi_search",
+ args: { query: "hello", scope: "own", limit: 3, note_id: "n1" },
+ setup: (client) => {
+ vi.mocked(client.search).mockResolvedValue([
+ {
+ id: "p1",
+ title: "match",
+ content_preview: null,
+ updated_at: "2026-01-01T00:00:00Z",
+ note_id: "n1",
+ },
+ ]);
+ },
+ assertClient: (client) => {
+ expect(client.search).toHaveBeenCalledWith({
+ query: "hello",
+ scope: "own",
+ limit: 3,
+ noteId: "n1",
+ });
+ },
+ },
+ {
+ name: "zedi_clip_url",
+ args: { url: "https://example.com/article" },
+ setup: (client) => {
+ vi.mocked(client.clipUrl).mockResolvedValue({ page_id: "p9", title: "Clipped" });
+ },
+ assertClient: (client) => {
+ expect(client.clipUrl).toHaveBeenCalledWith("https://example.com/article");
+ },
+ expectedPayload: { page_id: "p9", title: "Clipped" },
+ },
+];
+
describe("ALL_TOOL_NAMES", () => {
it("contains no duplicates", () => {
const set = new Set(ALL_TOOL_NAMES);
@@ -76,19 +414,6 @@ describe("ALL_TOOL_NAMES", () => {
});
it("includes the canonical user / pages / notes / search / clip surface", () => {
- // この list は意図的に `ALL_TOOL_NAMES` を二重化している。`registerAllTools` 側のテストは
- // 「実装と `ALL_TOOL_NAMES` がズレないこと」しか保証しないので、両方を一括で消した
- // (= 公開 API 縮退) 場合は検出できない。ここで仕様として固定したい一群のツール名を
- // ハードコードしておくことで、その縮退を CI で確実に止める。新規ツール追加時にこの
- // list の更新は不要 (`>=` 関係)、ただし既存ツールの削除/改名時は意図的な仕様変更として
- // この list も併せて更新すること。
- //
- // This list intentionally duplicates `ALL_TOOL_NAMES`. The `registerAllTools` test only
- // guarantees the registry stays consistent with `ALL_TOOL_NAMES`; if both were dropped
- // together (i.e. a silent public-API regression) it would still pass. Locking the
- // canonical surface here forces any tool removal/rename to be an explicit edit to this
- // list, surfacing it in code review. Adding a new tool does NOT require touching this
- // list (it's a "must contain" check, not an equality check).
const required = [
"zedi_get_current_user",
"zedi_list_pages",
@@ -117,15 +442,6 @@ describe("ALL_TOOL_NAMES", () => {
});
it("does not re-introduce retired write tools (read-only contract after #889 Phase 5)", () => {
- // Issue #889 Phase 5 で MCP は read-only に縮退した。Hocuspocus を経由しない
- // Y.Doc 書き込み (`zedi_update_page_content`) を将来うっかり戻さないよう、
- // 仕様として "retired" のリストを CI で固定する。書き込み機能を再導入する場合は
- // この list から名前を外し、Hocuspocus 経由で安全に書ける形を別 issue で設計する。
- //
- // After Issue #889 Phase 5 the MCP surface is read-only. This guard makes a
- // future regression — silently re-adding `zedi_update_page_content` — fail
- // CI. Bringing back write access requires both removing the entry here and
- // a new design that routes through Hocuspocus.
const retired = ["zedi_update_page_content"];
for (const name of retired) {
expect(ALL_TOOL_NAMES).not.toContain(name);
@@ -159,3 +475,62 @@ describe("registerAllTools", () => {
expect(registered).toHaveLength(ALL_TOOL_NAMES.length);
});
});
+
+describe("registerAllTools tool handlers", () => {
+ it.each(TOOL_HANDLER_CASES.filter(({ name }) => !TOOLS_COVERED_IN_SERVER_TEST.has(name)))(
+ "$name invokes client and returns JSON content",
+ async ({ name, args, setup, assertClient, expectedPayload }) => {
+ const client = createMockClient();
+ setup(client);
+ const { server, handlers } = createServerStub();
+ registerAllTools(server as unknown as McpServer, client);
+
+ const handler = handlers.get(name);
+ expect(handler, `handler for ${name} should be registered`).toBeDefined();
+ if (!handler) {
+ throw new Error(`handler for ${name} should be registered`);
+ }
+
+ const result = await handler(args);
+ assertClient(client);
+ const parsed = expectJsonContent(result);
+ if (expectedPayload !== undefined) {
+ expect(parsed).toEqual(expectedPayload);
+ }
+ },
+ );
+
+ it("zedi_delete_note returns isError when the API rejects the call", async () => {
+ const client = createMockClient();
+ vi.mocked(client.deleteNote).mockRejectedValue(new ZediApiError(403, "forbidden"));
+ const { server, handlers } = createServerStub();
+ registerAllTools(server as unknown as McpServer, client);
+
+ const deleteHandler = handlers.get("zedi_delete_note");
+ expect(deleteHandler).toBeDefined();
+ if (!deleteHandler) {
+ throw new Error("zedi_delete_note handler should be registered");
+ }
+ const result = await deleteHandler({ note_id: "n1" });
+ expect(result.isError).toBe(true);
+ expect(result.content[0]?.text).toContain("HTTP 403");
+ expect(result.content[0]?.text).toContain("forbidden");
+ });
+
+ it("rejects invalid tool arguments via MCP schema validation", async () => {
+ const client = createMockClient();
+ const server = createMcpServer(client);
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ await server.connect(serverTransport);
+ const mcpClient = new Client({ name: "test-client", version: "0.0.0" }, { capabilities: {} });
+ await mcpClient.connect(clientTransport);
+
+ const result = await mcpClient.callTool({
+ name: "zedi_add_note_member",
+ arguments: { note_id: "n1", email: "not-an-email", role: "viewer" },
+ });
+
+ expect(result.isError).toBe(true);
+ expect(client.addNoteMember).not.toHaveBeenCalled();
+ });
+});