Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds new service methods and MCP server tools to retrieve items from collections with cursor-based pagination, and to retrieve multiple items by ID, enabling more efficient “fetch many” workflows for clients consuming the MCP interface.
Changes:
- Added
ItemService.getItemsByIds()to fetch multiple items with an access check via collection membership. - Extended
CollectionService.getCollection()to support cursor/limit pagination and return pagination metadata. - Registered two new MCP tools:
get_collection_items(paginated) andget_items_by_id.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| apps/server/src/services/item.service.ts | Adds a multi-ID item fetch with collection-based access validation. |
| apps/server/src/services/collection.service.ts | Implements cursor pagination (limit + cursor) and returns pagination metadata. |
| apps/server/src/mcp/server.ts | Exposes new MCP tools for paginated collection reads and multi-ID item reads. |
| .vscode/settings.json | Updates workspace TypeScript SDK configuration. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async getItemsByIds(userId: string, itemIds: string[]) { | ||
| if (!itemIds || itemIds.length === 0) return []; | ||
|
|
||
| const results = await db | ||
| .select({ | ||
| ...getTableColumns(itemsTable), | ||
| }) | ||
| .from(itemsTable) | ||
| .where( | ||
| and( | ||
| inArray(itemsTable.id, itemIds), | ||
| exists( | ||
| db | ||
| .select() | ||
| .from(collectionItemsTable) | ||
| .innerJoin( | ||
| userCollectionsTable, | ||
| and( | ||
| eq( | ||
| userCollectionsTable.collectionId, | ||
| collectionItemsTable.collectionId, | ||
| ), | ||
| eq(userCollectionsTable.userId, userId), | ||
| ), | ||
| ) | ||
| .where(eq(collectionItemsTable.itemId, itemsTable.id)), | ||
| ), | ||
| ), | ||
| ); |
There was a problem hiding this comment.
Security/access-scope: this query only checks that the item is in any collection the user belongs to. For MCP API keys in collection_specific mode, this would allow fetching items from collections outside the key’s granted scope (since it’s only keyed by userId). Consider adding an optional allowedCollectionIds (or similar) parameter and include it in the exists join condition (e.g., restrict collectionItemsTable.collectionId / userCollectionsTable.collectionId) so callers can enforce API-key scoping at the data layer.
| // itemsTable.createdAt is descending, so we want items older (<) than the cursor | ||
| queryConditions.push(lt(itemsTable.createdAt, new Date(cursor))); |
There was a problem hiding this comment.
Cursor handling: new Date(cursor) will silently produce an Invalid Date for malformed input, which can lead to incorrect filtering or runtime issues when building the SQL. Prefer validating/parsing the cursor (e.g., reject invalid dates) and/or change the API to accept a Date (or enforce an ISO datetime string at validation boundaries) before constructing the query.
| // itemsTable.createdAt is descending, so we want items older (<) than the cursor | |
| queryConditions.push(lt(itemsTable.createdAt, new Date(cursor))); | |
| const cursorDate = new Date(cursor); | |
| if (Number.isNaN(cursorDate.getTime())) { | |
| throw new Error("Invalid cursor: expected an ISO 8601 datetime string"); | |
| } | |
| // itemsTable.createdAt is descending, so we want items older (<) than the cursor | |
| queryConditions.push(lt(itemsTable.createdAt, cursorDate)); |
| @@ -128,13 +144,31 @@ export class CollectionService { | |||
| collectionItemsTable, | |||
| eq(itemsTable.id, collectionItemsTable.itemId), | |||
| ) | |||
| .where(eq(collectionItemsTable.collectionId, collectionId)) | |||
| .where(and(...queryConditions)) | |||
| .orderBy(desc(itemsTable.createdAt)); | |||
|
|
|||
| // Fetch items ordered by createdAt (newest first). Add +1 to check for next page. | |||
| const items = limit ? await baseQuery.limit(limit + 1) : await baseQuery; | |||
|
|
|||
| let hasMore = false; | |||
| let nextCursor = null; | |||
|
|
|||
| if (limit && items.length > limit) { | |||
| hasMore = true; | |||
| items.pop(); // Remove the extra item | |||
| // Get the createdAt from the new last item for the next cursor | |||
| const lastItem = items[items.length - 1]; | |||
| nextCursor = lastItem ? lastItem.createdAt.toISOString() : null; | |||
| } | |||
There was a problem hiding this comment.
Pagination correctness: using only createdAt as the cursor with orderBy(createdAt DESC) can skip or duplicate items when multiple rows share the same createdAt (common under high write concurrency). To make pagination stable, include a tie-breaker in both ordering and cursor (e.g., (createdAt, id) with orderBy(createdAt DESC, id DESC) and a cursor containing both values).
There was a problem hiding this comment.
also, we are using uuid-7, vo time based sort ho jaati h @asutosh29, can use that here
There was a problem hiding this comment.
ok will keep that in mind while making the changes
| server.registerTool( | ||
| "get_collection_items", | ||
| { | ||
| description: | ||
| "Get items present within a specific collection using safe pagination", | ||
| inputSchema: z.object({ | ||
| collectionId: z | ||
| .string() | ||
| .describe("ID of the collection to fetch items from"), | ||
| limit: z | ||
| .number() | ||
| .min(1) | ||
| .max(50) | ||
| .optional() | ||
| .describe( | ||
| "Number of items to fetch. Min: 1, Max: 50. Recommended: 10-25.", | ||
| ), | ||
| cursor: z | ||
| .string() | ||
| .optional() | ||
| .describe( | ||
| "Pagination cursor (ISO date string) returned from a previous call. Do not provide on the first call.", | ||
| ), | ||
| }), | ||
| }, | ||
| async ({ collectionId, limit, cursor }) => { | ||
| const { actor } = getMcpContext(); | ||
|
|
||
| try { | ||
| const fetchLimit = limit || 25; // Default safe limit | ||
| const collection = await collectionService.getCollection( | ||
| collectionId, | ||
| actor.userId, | ||
| fetchLimit, | ||
| cursor, | ||
| ); | ||
|
|
There was a problem hiding this comment.
Access control: this tool does not enforce actor.mode === "collection_specific" scoping the way add_item_to_collection does. As a result, an API key granted to only some collections could still fetch items from any collection the owning user belongs to by guessing collectionId (the service layer only checks userId). Add an explicit hasAccess check against actor.grantedCollections/actor.userCollections before calling collectionService.getCollection.
| inputSchema: z.object({ | ||
| collectionId: z | ||
| .string() | ||
| .describe("ID of the collection to fetch items from"), | ||
| limit: z | ||
| .number() | ||
| .min(1) | ||
| .max(50) | ||
| .optional() | ||
| .describe( | ||
| "Number of items to fetch. Min: 1, Max: 50. Recommended: 10-25.", | ||
| ), | ||
| cursor: z | ||
| .string() | ||
| .optional() | ||
| .describe( | ||
| "Pagination cursor (ISO date string) returned from a previous call. Do not provide on the first call.", | ||
| ), | ||
| }), |
There was a problem hiding this comment.
Input validation: cursor is documented as an ISO date string but the schema accepts any string. Use Zod datetime validation (e.g., z.string().datetime()) to fail fast on malformed cursors instead of letting new Date(cursor) through to the DB query.
There was a problem hiding this comment.
schema validation for pagination is necessary and a good practice.
There was a problem hiding this comment.
Added .uuid() validation to the cursor field in the get_collection_items schema (commit f41e67b). Since we migrated to UUIDv7-based cursors, UUID format validation is the right constraint here — invalid cursors are now rejected at the schema level before reaching the DB query.
| server.registerTool( | ||
| "get_items_by_id", | ||
| { | ||
| description: | ||
| "Retrieve specific items by their IDs. Only specify IDs you are absolutely sure of. If you need to find IDs, use other available tools.", | ||
| inputSchema: z.object({ | ||
| itemIds: z | ||
| .array(z.string()) | ||
| .describe( | ||
| "List of item IDs to retrieve. Only provide valid IDs that you have obtained from other tools.", | ||
| ), | ||
| }), | ||
| }, | ||
| async ({ itemIds }) => { | ||
| const { actor } = getMcpContext(); | ||
|
|
||
| try { | ||
| const items = await itemService.getItemsByIds(actor.userId, itemIds); |
There was a problem hiding this comment.
Security/performance: itemIds is unbounded and not scoped to the API key’s granted collections. A large array could produce very expensive IN (...) queries, and a collection_specific key could retrieve items outside its allowed collection scope (the underlying query only checks userId). Add schema limits (e.g., .min(1).max(N)) and enforce access scope (either by passing allowed collection IDs into getItemsByIds or by rejecting IDs not in the key’s granted collections).
| id: collection.id, | ||
| title: collection.title, | ||
| role: collection.role, | ||
| itemCount: collection.items.length, |
There was a problem hiding this comment.
Response field naming: itemCount here is the count of items returned in the current page, not the total number of items in the collection. This can be misleading to tool consumers; consider renaming to resultCount/pageSize or adding a separate total count if that’s what you intend to expose.
| itemCount: collection.items.length, | |
| resultCount: collection.items.length, |
|
@copilot review |
Agent-Logs-Url: https://github.com/sdslabs/dex/sessions/dcd85f9c-a85e-450b-b363-02346867842f Co-authored-by: vrag99 <121307006+vrag99@users.noreply.github.com>
|
Good work. Approved |
No description provided.