From 3f4add5407b30469d1ae4c78dda2ca72618d111a Mon Sep 17 00:00:00 2001 From: "HIMURA Tomohiko a.k.a eiel" Date: Sat, 19 Jul 2025 18:30:34 +0900 Subject: [PATCH 1/3] feat: add pagination support to list_rooms tool with TTL cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change implements pagination for the list_rooms tool to improve performance and usability when dealing with large numbers of rooms. A TTL-based cache system was added to prevent unnecessary API calls when fetching room data. Changes: - Add pagination parameters (offset, limit) to listRoomsParamsSchema - Implement Cache class with TTL functionality for efficient data caching - Update listRooms implementation to use cache and support pagination - Modify server.ts to use new parameter schema for list_rooms tool 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/cache.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++ src/schema.ts | 19 +++++++++++++ src/server.ts | 10 ++++++- src/toolCallbacks.ts | 63 +++++++++++++++++++++++-------------------- 4 files changed, 126 insertions(+), 30 deletions(-) create mode 100644 src/cache.ts diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..ccd8c99 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,64 @@ +interface CacheEntry { + data: T; + timestamp: number; + ttl: number; +} + +export class Cache { + private cache = new Map>(); + + set(key: string, data: T, ttlMs: number): void { + this.cache.set(key, { + data, + timestamp: Date.now(), + ttl: ttlMs, + }); + } + + get(key: string): T | undefined { + const entry = this.cache.get(key); + if (!entry) { + return undefined; + } + + const now = Date.now(); + if (now - entry.timestamp > entry.ttl) { + this.cache.delete(key); + return undefined; + } + + return entry.data; + } + + has(key: string): boolean { + return this.get(key) !== undefined; + } + + clear(): void { + this.cache.clear(); + } + + delete(key: string): boolean { + return this.cache.delete(key); + } + + size(): number { + this.cleanExpired(); + return this.cache.size; + } + + private cleanExpired(): void { + const now = Date.now(); + for (const [key, entry] of this.cache.entries()) { + if (now - entry.timestamp > entry.ttl) { + this.cache.delete(key); + } + } + } +} + +export interface Room { + room_id: number; + name: string; + type: string; +} diff --git a/src/schema.ts b/src/schema.ts index 44c8183..c71eac7 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,5 +1,24 @@ import { z } from 'zod'; +/** @see https://developer.chatwork.com/reference/get-rooms */ +export const listRoomsParamsSchema = z + .object({ + offset: z + .number() + .int() + .min(0) + .default(0) + .describe('取得開始位置'), + limit: z + .number() + .int() + .min(1) + .max(100) + .default(100) + .describe('取得件数'), + }) + .describe('チャット一覧取得'); + /** @see https://developer.chatwork.com/reference/get-my-tasks */ export const listMyTasksParamsSchema = z .object({ diff --git a/src/server.ts b/src/server.ts index 85d24c5..a7ae406 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,6 +16,7 @@ import { listRoomFilesParamsSchema, listRoomMembersParamsSchema, listRoomMessagesParamsSchema, + listRoomsParamsSchema, listRoomTasksParamsSchema, postRoomMessageParamsSchema, readRoomMessagesParamsSchema, @@ -84,7 +85,14 @@ server.tool( '自分のコンタクト一覧を取得します。', listContacts, ); -server.tool('list_rooms', 'チャット一覧を取得します。', listRooms); +server.registerTool( + 'list_rooms', + { + description: 'チャット一覧を取得します。', + inputSchema: listRoomsParamsSchema.shape, + }, + listRooms, +); server.tool( 'create_room', '新しいグループチャットを作成します。', diff --git a/src/toolCallbacks.ts b/src/toolCallbacks.ts index 2cb5774..be758fd 100644 --- a/src/toolCallbacks.ts +++ b/src/toolCallbacks.ts @@ -1,5 +1,6 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { chatworkClient, ChatworkClientResponse } from './chatworkClient'; +import { Cache, Room } from './cache'; import { acceptIncomingRequestParamsSchema, createRoomLinkParamsSchema, @@ -17,6 +18,7 @@ import { listRoomFilesParamsSchema, listRoomMembersParamsSchema, listRoomMessagesParamsSchema, + listRoomsParamsSchema, listRoomTasksParamsSchema, postRoomMessageParamsSchema, readRoomMessagesParamsSchema, @@ -109,41 +111,44 @@ export const listContacts = () => }) .then(chatworkClientResponseToCallToolResult); -// TODO: 2500文字という閾値は直感によるもの。適切な値を調査する必要がある。 -/** レスポンス文字列が2500文字を超える場合プロパティを絞って返す */ -const minifyListRoomsResponse = ( - res: ChatworkClientResponse, -): ChatworkClientResponse => { - if (!res.ok || res.response.length < 2500) { - return res; - } +const roomsCache = new Cache(); +export const listRooms = async (args: z.infer): Promise => { + const { offset = 0, limit = 100 } = args; - const fullResponse: { - room_id: number; - name: string; - type: string; - }[] = JSON.parse(res.response); - const minifiedResponse = fullResponse.map(({ room_id, name, type }) => ({ - room_id, - name, - type, - })); - return { - ...res, - response: JSON.stringify(minifiedResponse), - }; -}; + const CACHE_KEY = 'all_rooms'; + const CACHE_TTL = 5 * 60 * 1000; // 5分 -export const listRooms = () => - chatworkClient() - .request({ + let allRooms = roomsCache.get(CACHE_KEY); + + if (!allRooms) { + const response = await chatworkClient().request({ path: '/rooms', method: 'GET', query: {}, body: {}, - }) - .then(minifyListRoomsResponse) - .then(chatworkClientResponseToCallToolResult); + }); + + if (!response.ok) { + return chatworkClientResponseToCallToolResult(response); + } + + allRooms = JSON.parse(response.response) as Room[]; + roomsCache.set(CACHE_KEY, allRooms, CACHE_TTL); + } + + const paginatedRooms = allRooms.slice(offset, offset + limit); + + const paginatedResponse: ChatworkClientResponse = { + ok: true, + status: 200, + response: JSON.stringify(paginatedRooms), + uri: '/rooms', + }; + + return chatworkClientResponseToCallToolResult( + paginatedResponse + ); +}; export const createRoom = (req: z.infer) => chatworkClient() From c98bf0eace789757d036e7a598742ee7f1a69578 Mon Sep 17 00:00:00 2001 From: "HIMURA Tomohiko a.k.a eiel" Date: Sat, 19 Jul 2025 19:16:28 +0900 Subject: [PATCH 2/3] refactor: migrate cache implementation from class-based to Redux with Zod validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the class-based cache system with Redux Toolkit for functional programming approach and add comprehensive Zod schema validation for ChatWork Room objects. Changes: - Remove src/cache.ts class-based implementation - Add Redux Toolkit store with TTL-enabled state management - Implement roomsSlice with pure function reducers and selectors - Add comprehensive Zod schema for Room type validation (src/types/room.ts) - Update listRooms to use Redux store and runtime validation - Add extensive unit tests for Redux slice and selectors - Maintain pagination functionality with improved type safety Benefits: - Functional programming approach with pure functions - Runtime type validation with Zod schemas - Immutable state management via Redux Toolkit - Enhanced extensibility for future middleware additions - Better testability with predictable state transitions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- package-lock.json | 70 ++++++++ package.json | 1 + src/cache.ts | 64 ------- src/store/index.ts | 28 +++ src/store/roomsSlice.test.ts | 338 +++++++++++++++++++++++++++++++++++ src/store/roomsSlice.ts | 70 ++++++++ src/toolCallbacks.ts | 26 +-- src/types/room.ts | 111 ++++++++++++ 8 files changed, 632 insertions(+), 76 deletions(-) delete mode 100644 src/cache.ts create mode 100644 src/store/index.ts create mode 100644 src/store/roomsSlice.test.ts create mode 100644 src/store/roomsSlice.ts create mode 100644 src/types/room.ts diff --git a/package-lock.json b/package-lock.json index 303a31e..9e05cc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.8.0", + "@reduxjs/toolkit": "^2.4.0", "zod": "^3.24.2" }, "bin": { @@ -760,6 +761,32 @@ "node": ">= 8" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", + "integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.43.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.43.0.tgz", @@ -1040,6 +1067,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tsconfig/strictest": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@tsconfig/strictest/-/strictest-2.0.5.tgz", @@ -2651,6 +2690,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3317,6 +3366,27 @@ "node": ">=0.10.0" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", diff --git a/package.json b/package.json index 3a8f5ea..1b8dc0a 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.8.0", + "@reduxjs/toolkit": "^2.4.0", "zod": "^3.24.2" } } diff --git a/src/cache.ts b/src/cache.ts deleted file mode 100644 index ccd8c99..0000000 --- a/src/cache.ts +++ /dev/null @@ -1,64 +0,0 @@ -interface CacheEntry { - data: T; - timestamp: number; - ttl: number; -} - -export class Cache { - private cache = new Map>(); - - set(key: string, data: T, ttlMs: number): void { - this.cache.set(key, { - data, - timestamp: Date.now(), - ttl: ttlMs, - }); - } - - get(key: string): T | undefined { - const entry = this.cache.get(key); - if (!entry) { - return undefined; - } - - const now = Date.now(); - if (now - entry.timestamp > entry.ttl) { - this.cache.delete(key); - return undefined; - } - - return entry.data; - } - - has(key: string): boolean { - return this.get(key) !== undefined; - } - - clear(): void { - this.cache.clear(); - } - - delete(key: string): boolean { - return this.cache.delete(key); - } - - size(): number { - this.cleanExpired(); - return this.cache.size; - } - - private cleanExpired(): void { - const now = Date.now(); - for (const [key, entry] of this.cache.entries()) { - if (now - entry.timestamp > entry.ttl) { - this.cache.delete(key); - } - } - } -} - -export interface Room { - room_id: number; - name: string; - type: string; -} diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..54601f6 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,28 @@ +import { configureStore } from '@reduxjs/toolkit'; +import roomsReducer from './roomsSlice'; + +export const store = configureStore({ + reducer: { + rooms: roomsReducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + // Disable serializability check for Date objects in TTL + serializableCheck: { + ignoredPaths: ['rooms.rooms.timestamp'], + }, + }) +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; + +// Re-export actions and selectors for convenience +export { + setRooms, + clearRooms, + cleanExpiredData, + selectRooms, + selectPaginatedRooms, +} from './roomsSlice'; +export type { Room } from '../types/room'; \ No newline at end of file diff --git a/src/store/roomsSlice.test.ts b/src/store/roomsSlice.test.ts new file mode 100644 index 0000000..5acab03 --- /dev/null +++ b/src/store/roomsSlice.test.ts @@ -0,0 +1,338 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import roomsReducer, { + setRooms, + clearRooms, + cleanExpiredData, + selectRooms, + selectPaginatedRooms, +} from './roomsSlice'; +import { Room } from '../types/room'; + +describe('roomsSlice', () => { + const mockRooms: Room[] = [ + { + room_id: 1, + name: 'Room 1', + type: 'group', + role: 'admin', + sticky: false, + unread_num: 0, + mention_num: 0, + mytask_num: 0, + message_num: 10, + file_num: 2, + task_num: 1, + icon_path: 'https://example.com/icon1.png', + last_update_time: 1719487723, + }, + { + room_id: 2, + name: 'Room 2', + type: 'direct', + role: 'member', + sticky: true, + unread_num: 5, + mention_num: 2, + mytask_num: 1, + message_num: 20, + file_num: 0, + task_num: 3, + icon_path: 'https://example.com/icon2.png', + last_update_time: 1719487724, + }, + { + room_id: 3, + name: 'Room 3', + type: 'group', + role: 'readonly', + sticky: false, + unread_num: 3, + mention_num: 0, + mytask_num: 2, + message_num: 15, + file_num: 5, + task_num: 0, + icon_path: 'https://example.com/icon3.png', + last_update_time: 1719487725, + }, + { + room_id: 4, + name: 'Room 4', + type: 'direct', + role: 'member', + sticky: false, + unread_num: 0, + mention_num: 1, + mytask_num: 0, + message_num: 8, + file_num: 1, + task_num: 2, + icon_path: 'https://example.com/icon4.png', + last_update_time: 1719487726, + }, + { + room_id: 5, + name: 'Room 5', + type: 'group', + role: 'admin', + sticky: true, + unread_num: 12, + mention_num: 3, + mytask_num: 4, + message_num: 50, + file_num: 8, + task_num: 6, + icon_path: 'https://example.com/icon5.png', + last_update_time: 1719487727, + }, + ]; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('reducers', () => { + it('should return the initial state', () => { + expect(roomsReducer(undefined, { type: 'unknown' })).toEqual({ + rooms: null, + }); + }); + + it('should handle setRooms', () => { + const ttl = 300000; // 5 minutes + const now = Date.now(); + vi.setSystemTime(now); + + const action = setRooms({ data: mockRooms, ttl }); + const result = roomsReducer(undefined, action); + + expect(result.rooms).toEqual({ + data: mockRooms, + timestamp: now, + ttl, + }); + }); + + it('should handle clearRooms', () => { + const initialState = { + rooms: { + data: mockRooms, + timestamp: Date.now(), + ttl: 300000, + }, + }; + + const result = roomsReducer(initialState, clearRooms()); + + expect(result.rooms).toBeNull(); + }); + + it('should handle cleanExpiredData - removes expired data', () => { + const ttl = 300000; // 5 minutes + const pastTime = Date.now() - ttl - 1000; // 1 second after expiry + + const initialState = { + rooms: { + data: mockRooms, + timestamp: pastTime, + ttl, + }, + }; + + const result = roomsReducer(initialState, cleanExpiredData()); + + expect(result.rooms).toBeNull(); + }); + + it('should handle cleanExpiredData - keeps valid data', () => { + const ttl = 300000; // 5 minutes + const recentTime = Date.now() - 60000; // 1 minute ago + + const initialState = { + rooms: { + data: mockRooms, + timestamp: recentTime, + ttl, + }, + }; + + const result = roomsReducer(initialState, cleanExpiredData()); + + expect(result.rooms).toEqual(initialState.rooms); + }); + + it('should handle cleanExpiredData - does nothing when rooms is null', () => { + const initialState = { rooms: null }; + + const result = roomsReducer(initialState, cleanExpiredData()); + + expect(result.rooms).toBeNull(); + }); + }); + + describe('selectors', () => { + describe('selectRooms', () => { + it('should return null when rooms is null', () => { + const state = { rooms: { rooms: null } }; + + const result = selectRooms(state); + + expect(result).toBeNull(); + }); + + it('should return rooms data when not expired', () => { + const ttl = 300000; // 5 minutes + const recentTime = Date.now() - 60000; // 1 minute ago + + const state = { + rooms: { + rooms: { + data: mockRooms, + timestamp: recentTime, + ttl, + }, + }, + }; + + const result = selectRooms(state); + + expect(result).toEqual(mockRooms); + }); + + it('should return null when data is expired', () => { + const ttl = 300000; // 5 minutes + const pastTime = Date.now() - ttl - 1000; // 1 second after expiry + + const state = { + rooms: { + rooms: { + data: mockRooms, + timestamp: pastTime, + ttl, + }, + }, + }; + + const result = selectRooms(state); + + expect(result).toBeNull(); + }); + }); + + describe('selectPaginatedRooms', () => { + const validState = { + rooms: { + rooms: { + data: mockRooms, + timestamp: Date.now() - 60000, // 1 minute ago + ttl: 300000, // 5 minutes + }, + }, + }; + + it('should return null when selectRooms returns null', () => { + const state = { rooms: { rooms: null } }; + + const result = selectPaginatedRooms(state, 0, 10); + + expect(result).toBeNull(); + }); + + it('should return paginated rooms with default offset and limit', () => { + const result = selectPaginatedRooms(validState); + + expect(result).toEqual(mockRooms); // All rooms (default limit 100) + }); + + it('should return paginated rooms with custom offset and limit', () => { + const result = selectPaginatedRooms(validState, 1, 2); + + expect(result).toEqual([ + { + room_id: 2, + name: 'Room 2', + type: 'direct', + role: 'member', + sticky: true, + unread_num: 5, + mention_num: 2, + mytask_num: 1, + message_num: 20, + file_num: 0, + task_num: 3, + icon_path: 'https://example.com/icon2.png', + last_update_time: 1719487724, + }, + { + room_id: 3, + name: 'Room 3', + type: 'group', + role: 'readonly', + sticky: false, + unread_num: 3, + mention_num: 0, + mytask_num: 2, + message_num: 15, + file_num: 5, + task_num: 0, + icon_path: 'https://example.com/icon3.png', + last_update_time: 1719487725, + }, + ]); + }); + + it('should handle offset beyond array length', () => { + const result = selectPaginatedRooms(validState, 10, 2); + + expect(result).toEqual([]); + }); + + it('should handle limit beyond remaining items', () => { + const result = selectPaginatedRooms(validState, 3, 10); + + expect(result).toEqual([ + { + room_id: 4, + name: 'Room 4', + type: 'direct', + role: 'member', + sticky: false, + unread_num: 0, + mention_num: 1, + mytask_num: 0, + message_num: 8, + file_num: 1, + task_num: 2, + icon_path: 'https://example.com/icon4.png', + last_update_time: 1719487726, + }, + { + room_id: 5, + name: 'Room 5', + type: 'group', + role: 'admin', + sticky: true, + unread_num: 12, + mention_num: 3, + mytask_num: 4, + message_num: 50, + file_num: 8, + task_num: 6, + icon_path: 'https://example.com/icon5.png', + last_update_time: 1719487727, + }, + ]); + }); + + it('should handle zero limit', () => { + const result = selectPaginatedRooms(validState, 0, 0); + + expect(result).toEqual([]); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/store/roomsSlice.ts b/src/store/roomsSlice.ts new file mode 100644 index 0000000..0854bcd --- /dev/null +++ b/src/store/roomsSlice.ts @@ -0,0 +1,70 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { type Room } from '../types/room'; + +interface CachedData { + data: T; + timestamp: number; + ttl: number; +} + +interface RoomsState { + rooms: CachedData | null; +} + +const initialState: RoomsState = { + rooms: null, +}; + +const roomsSlice = createSlice({ + name: 'rooms', + initialState, + reducers: { + setRooms: (state, action: PayloadAction<{ data: Room[]; ttl: number }>) => { + state.rooms = { + data: action.payload.data, + timestamp: Date.now(), + ttl: action.payload.ttl, + }; + }, + clearRooms: (state) => { + state.rooms = null; + }, + cleanExpiredData: (state) => { + if (state.rooms) { + const now = Date.now(); + if (now - state.rooms.timestamp > state.rooms.ttl) { + state.rooms = null; + } + } + }, + }, +}); + +export const { setRooms, clearRooms, cleanExpiredData } = roomsSlice.actions; + +// Selectors +export const selectRooms = (state: { rooms: RoomsState }): Room[] | null => { + if (!state.rooms.rooms) return null; + + const now = Date.now(); + const cachedData = state.rooms.rooms; + + if (now - cachedData.timestamp > cachedData.ttl) { + return null; + } + + return cachedData.data; +}; + +export const selectPaginatedRooms = ( + state: { rooms: RoomsState }, + offset: number = 0, + limit: number = 100 +): Room[] | null => { + const rooms = selectRooms(state); + if (!rooms) return null; + + return rooms.slice(offset, offset + limit); +}; + +export default roomsSlice.reducer; \ No newline at end of file diff --git a/src/toolCallbacks.ts b/src/toolCallbacks.ts index be758fd..01706e8 100644 --- a/src/toolCallbacks.ts +++ b/src/toolCallbacks.ts @@ -1,6 +1,7 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { chatworkClient, ChatworkClientResponse } from './chatworkClient'; -import { Cache, Room } from './cache'; +import { store, setRooms, selectPaginatedRooms } from './store'; +import { validateRoomsArray } from './types/room'; import { acceptIncomingRequestParamsSchema, createRoomLinkParamsSchema, @@ -111,16 +112,16 @@ export const listContacts = () => }) .then(chatworkClientResponseToCallToolResult); -const roomsCache = new Cache(); export const listRooms = async (args: z.infer): Promise => { const { offset = 0, limit = 100 } = args; - const CACHE_KEY = 'all_rooms'; const CACHE_TTL = 5 * 60 * 1000; // 5分 - let allRooms = roomsCache.get(CACHE_KEY); + // Check if we have cached rooms + let paginatedRooms = selectPaginatedRooms(store.getState(), offset, limit); - if (!allRooms) { + if (!paginatedRooms) { + // Cache miss or expired - fetch from API const response = await chatworkClient().request({ path: '/rooms', method: 'GET', @@ -132,12 +133,15 @@ export const listRooms = async (args: z.infer): Pr return chatworkClientResponseToCallToolResult(response); } - allRooms = JSON.parse(response.response) as Room[]; - roomsCache.set(CACHE_KEY, allRooms, CACHE_TTL); + const allRooms = validateRoomsArray(JSON.parse(response.response)); + + // Store in Redux with TTL + store.dispatch(setRooms({ data: allRooms, ttl: CACHE_TTL })); + + // Get paginated data from updated store + paginatedRooms = selectPaginatedRooms(store.getState(), offset, limit) || []; } - const paginatedRooms = allRooms.slice(offset, offset + limit); - const paginatedResponse: ChatworkClientResponse = { ok: true, status: 200, @@ -145,9 +149,7 @@ export const listRooms = async (args: z.infer): Pr uri: '/rooms', }; - return chatworkClientResponseToCallToolResult( - paginatedResponse - ); + return chatworkClientResponseToCallToolResult(paginatedResponse); }; export const createRoom = (req: z.infer) => diff --git a/src/types/room.ts b/src/types/room.ts new file mode 100644 index 0000000..92e2d0f --- /dev/null +++ b/src/types/room.ts @@ -0,0 +1,111 @@ +import { z } from 'zod'; + +/** + * Zod schema for ChatWork Room object + * @see https://developer.chatwork.com/reference/get-rooms + */ +export const roomSchema = z.object({ + room_id: z + .number() + .int() + .positive() + .describe('ルームID'), + + name: z + .string() + .min(1) + .describe('ルーム名'), + + type: z + .enum(['my', 'direct', 'group']) + .describe('ルームタイプ (my: マイチャット, direct: ダイレクトチャット, group: グループチャット)'), + + role: z + .enum(['admin', 'member', 'readonly']) + .describe('ルールでの自分の権限 (admin: 管理者, member: メンバー, readonly: 閲覧のみ)'), + + sticky: z + .boolean() + .describe('スティッキー (お気に入り) 設定'), + + unread_num: z + .number() + .int() + .min(0) + .describe('未読メッセージ数'), + + mention_num: z + .number() + .int() + .min(0) + .describe('自分宛てのメンション数'), + + mytask_num: z + .number() + .int() + .min(0) + .describe('自分が担当者のタスク数'), + + message_num: z + .number() + .int() + .min(0) + .describe('総メッセージ数'), + + file_num: z + .number() + .int() + .min(0) + .describe('ファイル数'), + + task_num: z + .number() + .int() + .min(0) + .describe('タスク数'), + + icon_path: z + .string() + .url() + .describe('ルームアイコンのURL'), + + last_update_time: z + .number() + .int() + .positive() + .describe('最終更新日時 (UNIX timestamp)'), +}); + +/** + * TypeScript type inferred from Zod schema + */ +export type Room = z.infer; + +/** + * Array of rooms schema + */ +export const roomsArraySchema = z.array(roomSchema); + +/** + * Validation helper functions + */ +export const validateRoom = (data: unknown): Room => { + return roomSchema.parse(data); +}; + +export const validateRoomsArray = (data: unknown): Room[] => { + return roomsArraySchema.parse(data); +}; + +/** + * Safe validation that returns null on failure + */ +export const safeValidateRoom = (data: unknown): Room | null => { + const result = roomSchema.safeParse(data); + return result.success ? result.data : null; +}; + +export const safeValidateRoomsArray = (data: unknown): Room[] | null => { + const result = roomsArraySchema.safeParse(data); + return result.success ? result.data : null; +}; \ No newline at end of file From d3bb14c52216355cb3c25e0e4b37ad2d3cc81a30 Mon Sep 17 00:00:00 2001 From: "HIMURA Tomohiko a.k.a eiel" Date: Sat, 19 Jul 2025 19:33:43 +0900 Subject: [PATCH 3/3] format: prettier --write . --- src/schema.ts | 15 +----- src/store/index.ts | 4 +- src/store/roomsSlice.test.ts | 10 ++-- src/store/roomsSlice.ts | 12 ++--- src/toolCallbacks.ts | 11 +++-- src/types/room.ts | 92 ++++++++++++------------------------ 6 files changed, 52 insertions(+), 92 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index c71eac7..20ca37c 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -3,19 +3,8 @@ import { z } from 'zod'; /** @see https://developer.chatwork.com/reference/get-rooms */ export const listRoomsParamsSchema = z .object({ - offset: z - .number() - .int() - .min(0) - .default(0) - .describe('取得開始位置'), - limit: z - .number() - .int() - .min(1) - .max(100) - .default(100) - .describe('取得件数'), + offset: z.number().int().min(0).default(0).describe('取得開始位置'), + limit: z.number().int().min(1).max(100).default(100).describe('取得件数'), }) .describe('チャット一覧取得'); diff --git a/src/store/index.ts b/src/store/index.ts index 54601f6..b79e6ac 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -11,7 +11,7 @@ export const store = configureStore({ serializableCheck: { ignoredPaths: ['rooms.rooms.timestamp'], }, - }) + }), }); export type RootState = ReturnType; @@ -25,4 +25,4 @@ export { selectRooms, selectPaginatedRooms, } from './roomsSlice'; -export type { Room } from '../types/room'; \ No newline at end of file +export type { Room } from '../types/room'; diff --git a/src/store/roomsSlice.test.ts b/src/store/roomsSlice.test.ts index 5acab03..acccc5e 100644 --- a/src/store/roomsSlice.test.ts +++ b/src/store/roomsSlice.test.ts @@ -134,7 +134,7 @@ describe('roomsSlice', () => { it('should handle cleanExpiredData - removes expired data', () => { const ttl = 300000; // 5 minutes const pastTime = Date.now() - ttl - 1000; // 1 second after expiry - + const initialState = { rooms: { data: mockRooms, @@ -151,7 +151,7 @@ describe('roomsSlice', () => { it('should handle cleanExpiredData - keeps valid data', () => { const ttl = 300000; // 5 minutes const recentTime = Date.now() - 60000; // 1 minute ago - + const initialState = { rooms: { data: mockRooms, @@ -187,7 +187,7 @@ describe('roomsSlice', () => { it('should return rooms data when not expired', () => { const ttl = 300000; // 5 minutes const recentTime = Date.now() - 60000; // 1 minute ago - + const state = { rooms: { rooms: { @@ -206,7 +206,7 @@ describe('roomsSlice', () => { it('should return null when data is expired', () => { const ttl = 300000; // 5 minutes const pastTime = Date.now() - ttl - 1000; // 1 second after expiry - + const state = { rooms: { rooms: { @@ -335,4 +335,4 @@ describe('roomsSlice', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/src/store/roomsSlice.ts b/src/store/roomsSlice.ts index 0854bcd..51eb92e 100644 --- a/src/store/roomsSlice.ts +++ b/src/store/roomsSlice.ts @@ -45,26 +45,26 @@ export const { setRooms, clearRooms, cleanExpiredData } = roomsSlice.actions; // Selectors export const selectRooms = (state: { rooms: RoomsState }): Room[] | null => { if (!state.rooms.rooms) return null; - + const now = Date.now(); const cachedData = state.rooms.rooms; - + if (now - cachedData.timestamp > cachedData.ttl) { return null; } - + return cachedData.data; }; export const selectPaginatedRooms = ( state: { rooms: RoomsState }, offset: number = 0, - limit: number = 100 + limit: number = 100, ): Room[] | null => { const rooms = selectRooms(state); if (!rooms) return null; - + return rooms.slice(offset, offset + limit); }; -export default roomsSlice.reducer; \ No newline at end of file +export default roomsSlice.reducer; diff --git a/src/toolCallbacks.ts b/src/toolCallbacks.ts index 01706e8..2d8e940 100644 --- a/src/toolCallbacks.ts +++ b/src/toolCallbacks.ts @@ -112,7 +112,9 @@ export const listContacts = () => }) .then(chatworkClientResponseToCallToolResult); -export const listRooms = async (args: z.infer): Promise => { +export const listRooms = async ( + args: z.infer, +): Promise => { const { offset = 0, limit = 100 } = args; const CACHE_TTL = 5 * 60 * 1000; // 5分 @@ -134,12 +136,13 @@ export const listRooms = async (args: z.infer): Pr } const allRooms = validateRoomsArray(JSON.parse(response.response)); - + // Store in Redux with TTL store.dispatch(setRooms({ data: allRooms, ttl: CACHE_TTL })); - + // Get paginated data from updated store - paginatedRooms = selectPaginatedRooms(store.getState(), offset, limit) || []; + paginatedRooms = + selectPaginatedRooms(store.getState(), offset, limit) || []; } const paginatedResponse: ChatworkClientResponse = { diff --git a/src/types/room.ts b/src/types/room.ts index 92e2d0f..287d1aa 100644 --- a/src/types/room.ts +++ b/src/types/room.ts @@ -1,74 +1,42 @@ import { z } from 'zod'; -/** +/** * Zod schema for ChatWork Room object * @see https://developer.chatwork.com/reference/get-rooms */ export const roomSchema = z.object({ - room_id: z - .number() - .int() - .positive() - .describe('ルームID'), - - name: z - .string() - .min(1) - .describe('ルーム名'), - + room_id: z.number().int().positive().describe('ルームID'), + + name: z.string().min(1).describe('ルーム名'), + type: z .enum(['my', 'direct', 'group']) - .describe('ルームタイプ (my: マイチャット, direct: ダイレクトチャット, group: グループチャット)'), - + .describe( + 'ルームタイプ (my: マイチャット, direct: ダイレクトチャット, group: グループチャット)', + ), + role: z .enum(['admin', 'member', 'readonly']) - .describe('ルールでの自分の権限 (admin: 管理者, member: メンバー, readonly: 閲覧のみ)'), - - sticky: z - .boolean() - .describe('スティッキー (お気に入り) 設定'), - - unread_num: z - .number() - .int() - .min(0) - .describe('未読メッセージ数'), - - mention_num: z - .number() - .int() - .min(0) - .describe('自分宛てのメンション数'), - - mytask_num: z - .number() - .int() - .min(0) - .describe('自分が担当者のタスク数'), - - message_num: z - .number() - .int() - .min(0) - .describe('総メッセージ数'), - - file_num: z - .number() - .int() - .min(0) - .describe('ファイル数'), - - task_num: z - .number() - .int() - .min(0) - .describe('タスク数'), - - icon_path: z - .string() - .url() - .describe('ルームアイコンのURL'), - + .describe( + 'ルールでの自分の権限 (admin: 管理者, member: メンバー, readonly: 閲覧のみ)', + ), + + sticky: z.boolean().describe('スティッキー (お気に入り) 設定'), + + unread_num: z.number().int().min(0).describe('未読メッセージ数'), + + mention_num: z.number().int().min(0).describe('自分宛てのメンション数'), + + mytask_num: z.number().int().min(0).describe('自分が担当者のタスク数'), + + message_num: z.number().int().min(0).describe('総メッセージ数'), + + file_num: z.number().int().min(0).describe('ファイル数'), + + task_num: z.number().int().min(0).describe('タスク数'), + + icon_path: z.string().url().describe('ルームアイコンのURL'), + last_update_time: z .number() .int() @@ -108,4 +76,4 @@ export const safeValidateRoom = (data: unknown): Room | null => { export const safeValidateRoomsArray = (data: unknown): Room[] | null => { const result = roomsArraySchema.safeParse(data); return result.success ? result.data : null; -}; \ No newline at end of file +};