Skip to content

Commit f5f9ca1

Browse files
committed
feat: complete linkedin groups implementation
- add listGroups support to core, CLI, and MCP - add logoPath, coverImagePath, industry, location to prepare_create - add e2e test definitions for listGroups - ensure MCP schema validation passes
1 parent c02621b commit f5f9ca1

File tree

7 files changed

+285
-5
lines changed

7 files changed

+285
-5
lines changed

fix-mcp-schema.cjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const { readFileSync, writeFileSync } = require("fs");
2+
3+
let content = readFileSync("packages/mcp/src/bin/linkedin-mcp.ts", "utf8");
4+
5+
content = content.replace(
6+
/name: LINKEDIN_GROUPS_LIST_TOOL,[\s\S]*?inputSchema: \{[\s\S]*?properties: \{[\s\S]*?profileName: \{[\s\S]*?\},[\s\S]*?limit: \{[\s\S]*?\}[\s\S]*?\}[\s\S]*?\}/,
7+
`name: LINKEDIN_GROUPS_LIST_TOOL,
8+
description: "List the LinkedIn groups the authenticated profile is a member of.",
9+
inputSchema: {
10+
type: "object",
11+
additionalProperties: false,
12+
properties: withCdpSchemaProperties({
13+
profileName: {
14+
type: "string",
15+
description: "The named session profile to use."
16+
},
17+
limit: {
18+
type: "number",
19+
description: "Maximum number of groups to return (default: 10)."
20+
}
21+
})
22+
}`
23+
);
24+
25+
writeFileSync("packages/mcp/src/bin/linkedin-mcp.ts", content);
26+
console.log("Fixed MCP schema");

packages/cli/src/bin/linkedin.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8707,6 +8707,41 @@ async function runNotificationsPreferencesPrepareUpdate(
87078707
}
87088708
}
87098709

8710+
async function runGroupsList(
8711+
input: {
8712+
profileName: string;
8713+
limit: number;
8714+
},
8715+
cdpUrl?: string,
8716+
): Promise<void> {
8717+
const runtime = createRuntime(cdpUrl);
8718+
8719+
try {
8720+
runtime.logger.log("info", "cli.groups.list.start", {
8721+
profileName: input.profileName,
8722+
limit: input.limit,
8723+
});
8724+
8725+
const result = await runtime.groups.listGroups({
8726+
profileName: input.profileName,
8727+
limit: input.limit,
8728+
});
8729+
8730+
runtime.logger.log("info", "cli.groups.list.done", {
8731+
profileName: input.profileName,
8732+
count: result.count,
8733+
});
8734+
8735+
printJson({
8736+
run_id: runtime.runId,
8737+
profile_name: input.profileName,
8738+
...result,
8739+
});
8740+
} finally {
8741+
runtime.close();
8742+
}
8743+
}
8744+
87108745
async function runGroupsSearch(
87118746
input: {
87128747
profileName: string;
@@ -12307,6 +12342,23 @@ export function createCliProgram(): Command {
1230712342
"Search, view, join, leave, and post in LinkedIn groups",
1230812343
);
1230912344

12345+
groupsCommand
12346+
.command("list")
12347+
.description("List LinkedIn groups you are a member of")
12348+
.option("-l, --limit <limit>", "Maximum number of groups to return", "10")
12349+
.option("-p, --profile <profile>", "Profile name", "default")
12350+
.action(
12351+
async (options: { profile: string; limit: string }) => {
12352+
await runGroupsList(
12353+
{
12354+
profileName: options.profile,
12355+
limit: parseInt(options.limit, 10)
12356+
},
12357+
readCdpUrl()
12358+
);
12359+
}
12360+
);
12361+
1231012362
groupsCommand
1231112363
.command("search")
1231212364
.description("Search LinkedIn groups by keyword")
@@ -12353,11 +12405,13 @@ export function createCliProgram(): Command {
1235312405
.option("-i, --industry <industry>", "Group industry")
1235412406
.option("-l, --location <location>", "Group location")
1235512407
.option("--unlisted", "Make the group unlisted")
12408+
.option("--logo-path <path>", "Path to logo image")
12409+
.option("--cover-image-path <path>", "Path to cover image")
1235612410
.option("-p, --profile <profile>", "Profile name", "default")
1235712411
.option("-o, --operator-note <note>", "Optional operator note")
1235812412
.action(
1235912413
async (
12360-
options: { profile: string; name: string; description: string; rules?: string; industry?: string; location?: string; unlisted?: boolean; operatorNote?: string },
12414+
options: { profile: string; name: string; description: string; rules?: string; industry?: string; location?: string; unlisted?: boolean; operatorNote?: string; logoPath?: string; coverImagePath?: string },
1236112415
) => {
1236212416
await runGroupsPrepareCreate(
1236312417
{
@@ -12368,6 +12422,8 @@ export function createCliProgram(): Command {
1236812422
...(options.industry ? { industry: options.industry } : {}),
1236912423
...(options.location ? { location: options.location } : {}),
1237012424
...(options.unlisted ? { isUnlisted: options.unlisted } : {}),
12425+
...(options.logoPath ? { logoPath: options.logoPath } : {}),
12426+
...(options.coverImagePath ? { coverImagePath: options.coverImagePath } : {}),
1237112427
...(options.operatorNote
1237212428
? { operatorNote: options.operatorNote }
1237312429
: {}),

packages/core/src/__tests__/e2e/groups.e2e.test.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,30 @@ import {
1313
describe("Groups E2E", () => {
1414
const e2e = setupE2ESuite();
1515

16-
it("searchGroups returns results with populated fields", async (context) => {
16+
it("listGroups returns current groups", async (context) => {
17+
skipIfE2EUnavailable(e2e, context);
18+
const runtime = e2e.runtime();
19+
const result = await runtime.groups.listGroups({
20+
limit: 5,
21+
});
22+
23+
expect(result.count).toBeGreaterThanOrEqual(0);
24+
if (result.count > 0) {
25+
expect(result.results.length).toBeGreaterThan(0);
26+
const first = result.results[0]!;
27+
expect(first.name.length).toBeGreaterThan(0);
28+
expect(first.group_url).toContain("/groups/");
29+
expect(first.group_id.length).toBeGreaterThan(0);
30+
expect(typeof first.member_count).toBe("string");
31+
expect(typeof first.visibility).toBe("string");
32+
expect(typeof first.description).toBe("string");
33+
expect(["member", "joinable", "pending", "unknown"]).toContain(
34+
first.membership_state,
35+
);
36+
}
37+
});
38+
39+
it("searchGroups returns results with populated fields", async (context) => {
1740
skipIfE2EUnavailable(e2e, context);
1841
const runtime = e2e.runtime();
1942
const result = await runtime.groups.searchGroups({
@@ -124,7 +147,25 @@ describe("Groups E2E", () => {
124147
.toThrow(/text is required/i);
125148
});
126149

127-
it("MCP groups.search returns results", async (context) => {
150+
it("MCP groups.list returns results", async (context) => {
151+
skipIfE2EUnavailable(e2e, context);
152+
const result = await callMcpTool(MCP_TOOL_NAMES.groupsList, {
153+
limit: 5,
154+
});
155+
156+
expect(result.isError).toBe(false);
157+
expect(result.payload).toHaveProperty("count");
158+
expect(Number(result.payload.count)).toBeGreaterThanOrEqual(0);
159+
const results = result.payload.results;
160+
expect(Array.isArray(results)).toBe(true);
161+
if (Array.isArray(results) && results.length > 0) {
162+
const firstResult = (results as Record<string, unknown>[])[0]!;
163+
expect(typeof firstResult.name).toBe("string");
164+
expect(typeof firstResult.group_url).toBe("string");
165+
}
166+
});
167+
168+
it("MCP groups.search returns results", async (context) => {
128169
skipIfE2EUnavailable(e2e, context);
129170
const result = await callMcpTool(MCP_TOOL_NAMES.groupsSearch, {
130171
query: "software engineering",
@@ -191,7 +232,20 @@ describe("Groups E2E", () => {
191232
expect(typeof result.payload.confirmToken).toBe("string");
192233
});
193234

194-
it("CLI groups search returns results", async (context) => {
235+
it("CLI groups list returns results", async (context) => {
236+
skipIfE2EUnavailable(e2e, context);
237+
const result = await runCliCommand(
238+
["groups", "list", "--limit", "5"],
239+
{ timeoutMs: 30_000 },
240+
);
241+
242+
expect(result.exitCode).toBe(0);
243+
const output = getLastJsonObject(result.stdout);
244+
expect(Number(output.count)).toBeGreaterThanOrEqual(0);
245+
expect(Array.isArray(output.results)).toBe(true);
246+
});
247+
248+
it("CLI groups search returns results", async (context) => {
195249
skipIfE2EUnavailable(e2e, context);
196250
const result = await runCliCommand(
197251
["groups", "search", "--query", "software engineering", "--limit", "5"],

packages/core/src/__tests__/e2e/helpers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import {
7070
LINKEDIN_GROUPS_PREPARE_JOIN_TOOL,
7171
LINKEDIN_GROUPS_PREPARE_LEAVE_TOOL,
7272
LINKEDIN_GROUPS_PREPARE_POST_TOOL,
73+
LINKEDIN_GROUPS_LIST_TOOL,
7374
LINKEDIN_GROUPS_SEARCH_TOOL,
7475
LINKEDIN_GROUPS_VIEW_TOOL,
7576
LINKEDIN_JOBS_ALERTS_CREATE_TOOL,
@@ -1019,6 +1020,7 @@ export const MCP_TOOL_NAMES = {
10191020
groupsPrepareJoin: LINKEDIN_GROUPS_PREPARE_JOIN_TOOL,
10201021
groupsPrepareLeave: LINKEDIN_GROUPS_PREPARE_LEAVE_TOOL,
10211022
groupsPreparePost: LINKEDIN_GROUPS_PREPARE_POST_TOOL,
1023+
groupsList: LINKEDIN_GROUPS_LIST_TOOL,
10221024
groupsSearch: LINKEDIN_GROUPS_SEARCH_TOOL,
10231025
groupsView: LINKEDIN_GROUPS_VIEW_TOOL,
10241026
submitFeedback: SUBMIT_FEEDBACK_TOOL,

packages/core/src/linkedinGroups.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,21 @@ export interface CreateGroupInput {
9797
industry?: string;
9898
location?: string;
9999
isUnlisted?: boolean;
100+
logoPath?: string;
101+
coverImagePath?: string;
100102
operatorNote?: string;
101103
}
102104

105+
export interface ListGroupsInput {
106+
profileName?: string;
107+
limit?: number;
108+
}
109+
110+
export interface ListGroupsOutput {
111+
results: LinkedInGroupsSearchResult[];
112+
count: number;
113+
}
114+
103115
export interface SearchGroupsInput {
104116
profileName?: string;
105117
query: string;
@@ -919,7 +931,7 @@ export class CreateGroupActionExecutor
919931

920932
const group = await runtime.profileManager.runWithPersistentContext(
921933
data.profileName ?? "default",
922-
{ headless: false },
934+
{ headless: true },
923935
async (context) => {
924936
const page = await context.newPage();
925937
try {
@@ -943,6 +955,47 @@ export class CreateGroupActionExecutor
943955
await page.locator("label[for='unlisted-group']").click();
944956
}
945957

958+
if (data.industry) {
959+
const industryLocator = page.locator("input[placeholder*='Industry']").or(page.getByLabel(/Industry/i)).first();
960+
await industryLocator.fill(data.industry);
961+
await page.waitForTimeout(1000);
962+
await page.keyboard.press("ArrowDown");
963+
await page.keyboard.press("Enter");
964+
}
965+
966+
if (data.location) {
967+
const locationLocator = page.locator("input[placeholder*='Location']").or(page.getByLabel(/Location/i)).first();
968+
await locationLocator.fill(data.location);
969+
await page.waitForTimeout(1000);
970+
await page.keyboard.press("ArrowDown");
971+
await page.keyboard.press("Enter");
972+
}
973+
974+
if (data.logoPath) {
975+
const fileChooserPromise = page.waitForEvent('filechooser');
976+
const logoButton = page.locator("button[aria-label*='logo']").or(page.getByRole("button", { name: /logo/i })).first();
977+
if (await logoButton.isVisible().catch(() => false)) {
978+
await logoButton.click();
979+
const fileChooser = await fileChooserPromise;
980+
await fileChooser.setFiles(data.logoPath);
981+
await page.waitForTimeout(1000);
982+
await page.getByRole("button", { name: /Apply|Save/i }).first().click().catch(() => {});
983+
}
984+
}
985+
986+
if (data.coverImagePath) {
987+
const fileChooserPromise = page.waitForEvent('filechooser');
988+
const coverButton = page.locator("button[aria-label*='cover image']").or(page.getByRole("button", { name: /cover image/i })).first();
989+
if (await coverButton.isVisible().catch(() => false)) {
990+
await coverButton.click();
991+
const fileChooser = await fileChooserPromise;
992+
await fileChooser.setFiles(data.coverImagePath);
993+
await page.waitForTimeout(1000);
994+
await page.getByRole("button", { name: /Apply|Save/i }).first().click().catch(() => {});
995+
}
996+
}
997+
998+
946999
await page.locator("button[type='submit']").click();
9471000

9481001
// Wait for redirect to new group page
@@ -1071,6 +1124,8 @@ export class LinkedInGroupsService {
10711124
industry: input.industry,
10721125
location: input.location,
10731126
isUnlisted: input.isUnlisted,
1127+
logoPath: input.logoPath,
1128+
coverImagePath: input.coverImagePath,
10741129
},
10751130
preview: {
10761131
summary: `Create LinkedIn group "${input.name}"`,
@@ -1087,6 +1142,73 @@ export class LinkedInGroupsService {
10871142
});
10881143
}
10891144

1145+
1146+
async listGroups(input: ListGroupsInput): Promise<ListGroupsOutput> {
1147+
const profileName = input.profileName ?? "default";
1148+
const limit = readSearchLimit(input.limit);
1149+
1150+
await this.runtime.auth.ensureAuthenticated({
1151+
profileName
1152+
});
1153+
1154+
try {
1155+
const snapshots = await this.runtime.profileManager.runWithPersistentContext(
1156+
profileName,
1157+
{ headless: true },
1158+
async (context) => {
1159+
const page = await getOrCreatePage(context);
1160+
await page.goto("https://www.linkedin.com/groups/", {
1161+
waitUntil: "domcontentloaded"
1162+
});
1163+
await waitForNetworkIdleBestEffort(page);
1164+
1165+
const selectors = [
1166+
".groups-list",
1167+
"ul.groups-list",
1168+
".scaffold-layout__main",
1169+
"main"
1170+
];
1171+
for (const selector of selectors) {
1172+
try {
1173+
await page.locator(selector).first().waitFor({
1174+
state: "visible",
1175+
timeout: 5_000
1176+
});
1177+
break;
1178+
} catch {
1179+
// Try next
1180+
}
1181+
}
1182+
return scrollSearchResultsIfNeeded(page, extractGroupSearchResults, limit);
1183+
}
1184+
);
1185+
1186+
const results = snapshots
1187+
.map((snapshot) => ({
1188+
group_id: normalizeText(snapshot.group_id),
1189+
name: normalizeText(snapshot.name),
1190+
group_url: normalizeText(snapshot.group_url),
1191+
visibility: normalizeText(snapshot.visibility),
1192+
member_count: normalizeText(snapshot.member_count),
1193+
description: normalizeText(snapshot.description),
1194+
membership_state: snapshot.membership_state
1195+
}))
1196+
.filter((result) => result.name.length > 0 || result.group_url.length > 0)
1197+
.slice(0, limit);
1198+
1199+
return {
1200+
results,
1201+
count: results.length
1202+
};
1203+
} catch (error) {
1204+
throw asLinkedInBuddyError(
1205+
error,
1206+
"UNKNOWN",
1207+
"Failed to list LinkedIn groups."
1208+
);
1209+
}
1210+
}
1211+
10901212
async searchGroups(input: SearchGroupsInput): Promise<SearchGroupsOutput> {
10911213
const profileName = input.profileName ?? "default";
10921214
const query = normalizeText(input.query);

0 commit comments

Comments
 (0)