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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/__tests__/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

6 changes: 5 additions & 1 deletion src/tools/createGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,17 @@ 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: {
"accept": "application/json",
"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.
Expand Down Expand Up @@ -62,3 +65,4 @@ export function registerCreateGroupTool(server: McpServer) {
}
);
}

50 changes: 31 additions & 19 deletions src/tools/getGroups.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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);
Expand All @@ -64,3 +75,4 @@ export function registerGetGroupsTool(server: McpServer) {
}
);
}

16 changes: 11 additions & 5 deletions src/tools/updateGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -28,7 +28,12 @@ export function registerUpdateGroupTool(server: McpServer) {
};
}
try {
const { id, ...rest } = args;
const { id, name, status, isFavourite } = args;
const body: Record<string, unknown> = {};
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",
Expand All @@ -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) {
Expand Down Expand Up @@ -66,3 +71,4 @@ export function registerUpdateGroupTool(server: McpServer) {
}
);
}

2 changes: 1 addition & 1 deletion src/types/updateRuleSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function updateRuleSchema<T extends z.infer<typeof RuleTypeEnum>>(
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
Expand Down