From e7dcf4c04135ce32deda9a35a9c78527f139c6fd Mon Sep 17 00:00:00 2001 From: Tyagiquamar Date: Fri, 4 Sep 2026 22:11:57 +0530 Subject: [PATCH] fix: support single group lookup and allow partial updates in group tools --- src/__tests__/tools.test.ts | 89 ++++++++++++++++++++++++++++++++++ src/tools/createGroup.ts | 6 ++- src/tools/getGroups.ts | 50 +++++++++++-------- src/tools/updateGroup.ts | 16 ++++-- src/types/updateRuleSchemas.ts | 2 +- 5 files changed, 137 insertions(+), 26 deletions(-) diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts index 948590a..0b06adf 100644 --- a/src/__tests__/tools.test.ts +++ b/src/__tests__/tools.test.ts @@ -411,3 +411,92 @@ describe('Delete Rule Tool', () => { expect(globalThis.fetch as any).not.toHaveBeenCalled(); }); }); + +describe('Group Tools', () => { + let originalFetch: typeof globalThis.fetch; + let originalEnv: string | undefined; + + beforeEach(() => { + originalFetch = globalThis.fetch; + originalEnv = process.env.REQUESTLY_API_KEY; + process.env.REQUESTLY_API_KEY = 'test-api-key'; + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true, data: { id: 'grp_123' } }), + }) as any; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + process.env.REQUESTLY_API_KEY = originalEnv; + }); + + async function captureToolHandler(toolName: string, registerFn: (server: McpServer) => void) { + let toolHandler: Function | null = null; + const server = new McpServer({ name: 'test', version: '1.0.0' }); + const origRegister = server.registerTool.bind(server); + + server.registerTool = ((name: string, config: any, handler: any) => { + if (name === toolName) { + toolHandler = handler; + } + return origRegister(name, config, handler); + }) as any; + + registerFn(server); + if (!toolHandler) throw new Error(`${toolName} handler not captured`); + return toolHandler; + } + + it('create_group constructs clean payload', async () => { + const { registerCreateGroupTool } = await import('../tools/createGroup.js'); + const handler = await captureToolHandler('create_group', registerCreateGroupTool); + + await handler({ name: 'Test Group', status: 'Active', isFavourite: true, extraArg: 'leaked' }); + const fetchCall = (globalThis.fetch as any).mock.calls[0]; + expect(fetchCall[0]).toBe('https://api2.requestly.io/v1/groups'); + expect(fetchCall[1].method).toBe('POST'); + const body = JSON.parse(fetchCall[1].body); + expect(body).toEqual({ name: 'Test Group', status: 'Active', isFavourite: true }); + }); + + it('get_groups fetches all groups or single group by groupId', async () => { + const { registerGetGroupsTool } = await import('../tools/getGroups.js'); + const handler = await captureToolHandler('get_groups', registerGetGroupsTool); + + // Fetch all groups + await handler({}); + expect((globalThis.fetch as any).mock.calls[0][0]).toBe('https://api2.requestly.io/v1/groups'); + + // Fetch by groupId + await handler({ groupId: 'Group_123' }); + expect((globalThis.fetch as any).mock.calls[1][0]).toBe('https://api2.requestly.io/v1/groups/Group_123'); + }); + + it('update_group allows partial updates without forcing status or isFavourite defaults', async () => { + const { registerUpdateGroupTool } = await import('../tools/updateGroup.js'); + const handler = await captureToolHandler('update_group', registerUpdateGroupTool); + + await handler({ id: 'grp_123', name: 'Updated Name Only' }); + const fetchCall = (globalThis.fetch as any).mock.calls[0]; + expect(fetchCall[0]).toBe('https://api2.requestly.io/v1/groups/grp_123'); + expect(fetchCall[1].method).toBe('PUT'); + const body = JSON.parse(fetchCall[1].body); + expect(body).toEqual({ name: 'Updated Name Only' }); + expect(body.status).toBeUndefined(); + expect(body.isFavourite).toBeUndefined(); + }); + + it('delete_group deletes group when confirm is true', async () => { + const { registerDeleteGroupTool } = await import('../tools/deleteGroup.js'); + const handler = await captureToolHandler('delete_group', registerDeleteGroupTool); + + const result = await handler({ id: 'grp_123', confirm: true }); + expect(result.content[0].text).toContain('success'); + const fetchCall = (globalThis.fetch as any).mock.calls[0]; + expect(fetchCall[0]).toBe('https://api2.requestly.io/v1/groups/grp_123'); + expect(fetchCall[1].method).toBe('DELETE'); + }); +}); + diff --git a/src/tools/createGroup.ts b/src/tools/createGroup.ts index caa96c6..42210df 100644 --- a/src/tools/createGroup.ts +++ b/src/tools/createGroup.ts @@ -27,6 +27,9 @@ export function registerCreateGroupTool(server: McpServer) { }; } try { + const { name, status, isFavourite } = args; + const body = { name, status, isFavourite }; + const response = await fetch(`${REQUESTLY_API_BASE}/groups`, { method: "POST", headers: { @@ -34,7 +37,7 @@ export function registerCreateGroupTool(server: McpServer) { "content-type": "application/json", "x-api-key": apiKey, }, - body: JSON.stringify(args), + body: JSON.stringify(body), }); if (!response.ok) { // RQ-3025: status only — never reflect the upstream body. @@ -62,3 +65,4 @@ export function registerCreateGroupTool(server: McpServer) { } ); } + diff --git a/src/tools/getGroups.ts b/src/tools/getGroups.ts index 8d562d2..6439744 100644 --- a/src/tools/getGroups.ts +++ b/src/tools/getGroups.ts @@ -1,19 +1,23 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { REQUESTLY_API_BASE, apiErrorResult } from "../apiClient.js"; +import { REQUESTLY_API_BASE, buildResourceUrl, apiErrorResult, resourceIdSchema } from "../apiClient.js"; + +export const getGroupsInputSchema = { + groupId: resourceIdSchema.optional().describe("Unique ID of the group to retrieve. If omitted, retrieves all groups."), + offset: z.number().int().min(0).optional().describe("Index to start results from (for pagination)."), + pageSize: z.number().int().min(1).max(75).optional().describe("Number of results to return (max 75)."), +}; export function registerGetGroupsTool(server: McpServer) { server.registerTool( "get_groups", { title: "Get Groups", - description: "Get all groups in Requestly.", - inputSchema: { - offset: z.number().optional().default(0), - pageSize: z.number().optional().default(30), - }, + description: + "Retrieve all groups or a specific group from Requestly using its API. Supports pagination and lookup by groupId.", + inputSchema: getGroupsInputSchema, }, - async (args)=> { + async (args) => { const apiKey = process.env.REQUESTLY_API_KEY; if (!apiKey) { return { @@ -25,19 +29,26 @@ export function registerGetGroupsTool(server: McpServer) { ], }; } + const { groupId, offset, pageSize } = args; + let url = `${REQUESTLY_API_BASE}/groups`; + if (groupId) { + url = buildResourceUrl("groups", groupId); + } else { + const params = []; + if (offset !== undefined) params.push(`offset=${offset}`); + if (pageSize !== undefined) params.push(`pageSize=${pageSize}`); + if (params.length > 0) { + url += `?${params.join("&")}`; + } + } try { - const params = new URLSearchParams(); - if (typeof args.offset === "number") params.append("offset", String(args.offset)); - if (typeof args.pageSize === "number") params.append("pageSize", String(args.pageSize)); - const response = await fetch(`${REQUESTLY_API_BASE}/groups?${params.toString()}`, - { - method: "GET", - headers: { - "accept": "application/json", - "x-api-key": apiKey, - }, - } - ); + const response = await fetch(url, { + method: "GET", + headers: { + "accept": "application/json", + "x-api-key": apiKey, + }, + }); if (!response.ok) { // RQ-3025: status only — never reflect the upstream body. return await apiErrorResult("get groups", response); @@ -64,3 +75,4 @@ export function registerGetGroupsTool(server: McpServer) { } ); } + diff --git a/src/tools/updateGroup.ts b/src/tools/updateGroup.ts index 3a52534..c2fa26a 100644 --- a/src/tools/updateGroup.ts +++ b/src/tools/updateGroup.ts @@ -10,9 +10,9 @@ export function registerUpdateGroupTool(server: McpServer) { description: "Update a specific group in Requestly.", inputSchema: { id: resourceIdSchema.describe("Unique identifier of the group to update."), - name: z.string().describe("New name of the group."), - status: z.enum(["Active", "Inactive"]).optional().default("Active"), - isFavourite: z.boolean().optional().default(false), + name: z.string().optional().describe("New name of the group."), + status: z.enum(["Active", "Inactive"]).optional().describe("Status of the group."), + isFavourite: z.boolean().optional().describe("Whether the group is marked as favourite."), }, }, async (args) => { @@ -28,7 +28,12 @@ export function registerUpdateGroupTool(server: McpServer) { }; } try { - const { id, ...rest } = args; + const { id, name, status, isFavourite } = args; + const body: Record = {}; + if (name !== undefined) body.name = name; + if (status !== undefined) body.status = status; + if (isFavourite !== undefined) body.isFavourite = isFavourite; + const response = await fetch(buildResourceUrl("groups", id), { method: "PUT", @@ -37,7 +42,7 @@ export function registerUpdateGroupTool(server: McpServer) { "content-type": "application/json", "x-api-key": apiKey, }, - body: JSON.stringify(rest), + body: JSON.stringify(body), } ); if (!response.ok) { @@ -66,3 +71,4 @@ export function registerUpdateGroupTool(server: McpServer) { } ); } + diff --git a/src/types/updateRuleSchemas.ts b/src/types/updateRuleSchemas.ts index 4f3e3db..78737a2 100644 --- a/src/types/updateRuleSchemas.ts +++ b/src/types/updateRuleSchemas.ts @@ -43,7 +43,7 @@ function updateRuleSchema>( const createMCPCompatibleSchema = () => { // Base fields that are common to all rules const baseFields = { - ruleId: resourceIdSchema.describe('Unique identifier for the rule.').optional(), + ruleId: resourceIdSchema.describe('Unique identifier for the rule.'), name: z.string().describe('Name of the rule.').optional(), description: z.string().optional().describe('Description of the rule.'), ruleType: z